feat: brain-engine + brain-ui + docs — template full stack standalone

- brain-engine: server, embed, search, RAG, MCP, start.sh (standalone)
- brain-ui: source React complète, build.sh, DocsView avec tier colors
- docs: 14 pages guides humains (getting-started, architecture, sessions, workflows, agents, vues tier)
- brain-compose.yml v0.9.0: tier featured ajouté, sessions/agents par tier, coach_level, API key schema
- DISTRIBUTION_CHECKLIST v1.2: brain-engine + brain-ui + docs dans la checklist
This commit is contained in:
2026-03-20 20:25:40 +01:00
parent c249d417f5
commit 8244a07881
93 changed files with 12088 additions and 34 deletions

View File

@@ -34,8 +34,11 @@ Attendu : **0 résultats**.
``` ```
brain-template/ brain-template/
agents/ ← tous les agents dépersonnalisés agents/ ← tous les agents dépersonnalisés
contexts/ ← sessions génériques (9 fichiers) contexts/ ← sessions génériques (10 fichiers)
agent-memory/ ← README + _template/ agent-memory/ ← README + _template/
brain-engine/ ← moteur local (server, embed, search, RAG, MCP)
brain-ui/ ← dashboard React (docs, workflows, cosmos)
docs/ ← guides humains (14 pages)
profil/ profil/
decisions/ ← ADRs (placeholders domaine) decisions/ ← ADRs (placeholders domaine)
collaboration.md.example collaboration.md.example
@@ -67,28 +70,59 @@ brain-template/
**Exclus** (trop owner-specific) : `session-infra.yml`, `session-deploy.yml`, **Exclus** (trop owner-specific) : `session-infra.yml`, `session-deploy.yml`,
`session-urgence.yml`, `session-capital.yml`, `session-handoff.yml` `session-urgence.yml`, `session-capital.yml`, `session-handoff.yml`
> v1.0 → v1.1 : `session-brain.yml` ajouté (10e contexte) — sessions de travail sur le brain lui-même, 100% générique.
--- ---
## Docs (guides humains)
**v1.1 : docs/ inclus — 14 pages.**
Guides humains lisibles sans contexte brain : getting-started, architecture, sessions, workflows, agents par famille, vues par tier.
```
docs/
README.md ← index
getting-started.md ← premiere page — "j'ai forke, quoi maintenant ?"
architecture.md ← comment les pieces s'assemblent
sessions.md ← types, permissions, metabolisme, close
workflows.md ← recettes d'agents par situation
agents.md ← vue d'ensemble + comparatif tiers
agents-code.md ← review, securite, tests, refacto, perf
agents-infra.md ← VPS, CI/CD, monitoring, mail
agents-brain.md ← coach, scribes, orchestration, kernel
vue-tiers.md ← comparatif tous tiers
vue-free.md ← detail tier free
vue-featured.md ← detail tier featured
vue-pro.md ← detail tier pro
vue-full.md ← detail tier full
```
**Audit avant release :** `grep -ri "tetardtek" docs/` → 0 resultats.
## Wiki ## Wiki
**v1.0 : wiki absent (Option A).** **v1.0 : wiki absent.**
Le nouvel utilisateur construit son wiki au fil des sessions. Le nouvel utilisateur construit son wiki au fil des sessions via `wiki-scribe`.
Le wiki se construit naturellement via `wiki-scribe` en session. Le wiki est technique (audience agents) — le docs/ couvre l'onboarding humain.
Si un wiki starter est ajouté en v2.0 : auditer chaque fichier avant inclusion.
--- ---
## Checklist avant release ## Checklist avant release
- [ ] `grep tetardtek` → 0 résultats - [ ] `grep tetardtek` → 0 résultats
- [ ] `ls contexts/`9 fichiers présents - [ ] `ls contexts/`10 fichiers présents
- [ ] `ls agent-memory/` → README.md + _template/ - [ ] `ls agent-memory/` → README.md + _template/
- [ ] README.md lisible par un inconnu (pas de référence owner) - [ ] README.md lisible par un inconnu (pas de référence owner)
- [ ] `ls docs/` → 14 fichiers présents
- [ ] `grep -ri "tetardtek" docs/` → 0 résultats
- [ ] `ls brain-engine/` → server.py, embed.py, search.py, start.sh présents
- [ ] `grep -ri "tetardtek" brain-engine/` → 0 résultats
- [ ] `ls brain-ui/src/` → composants présents
- [ ] `grep -ri "tetardtek" brain-ui/src/` → 0 résultats
- [ ] PATHS.md vide / exemple — aucun chemin machine réel - [ ] PATHS.md vide / exemple — aucun chemin machine réel
- [ ] `brain-compose.local.yml.example` → aucun token/credential réel - [ ] `brain-compose.local.yml.example` → aucun token/credential réel
- [ ] Tag git `vX.Y.Z` créé après vérification - [ ] Tag git `vX.Y.Z` créé après vérification
--- ---
*Dernière mise à jour : 2026-03-18 — v1.0 distribution-ready* *Dernière mise à jour : 2026-03-20 — v1.2 docs + brain-engine + brain-ui standalone*

View File

@@ -3,21 +3,44 @@
# Copier depuis brain-compose.local.yml.example, remplir, NE PAS commiter. # Copier depuis brain-compose.local.yml.example, remplir, NE PAS commiter.
kernel_path: <BRAIN_ROOT> kernel_path: <BRAIN_ROOT>
kernel_version: "0.2.0" kernel_version: "0.9.0"
last_kernel_sync: "<YYYY-MM-DD>" last_kernel_sync: "<YYYY-MM-DD>"
machine: <MACHINE_NAME>
instances: instances:
prod: prod:
path: <BRAIN_ROOT> path: <BRAIN_ROOT>
brain_name: prod brain_name: prod
feature_set: full
config_status: hydrated # hydrated / partial / empty
active: true active: true
config_status: empty # empty → partial → hydrated (après brain-setup.sh)
mode: prod
# Exemple — instance client ou template-test : # Brain API Key — optionnelle
# template-test: # Sans clé → tier: free (le brain fonctionne sans restriction sur les fondamentaux)
# path: <BRAIN_ROOT>-test # Avec clé → tier validé au boot par key-guardian (free / featured / pro / full)
# brain_name: template-test # Obtenir une clé : voir docs/getting-started.md (futur)
# feature_set: full brain_api_key: null
# config_status: partial
# active: false # feature_set — écrit automatiquement par key-guardian au boot
# NE PAS modifier manuellement — sera écrasé à chaque validation
feature_set:
tier: free
agents: []
contexts: []
distillation: false
last_validated_at: null
expires_at: null
grace_until: null
# docs_fetch — comment le brain accède aux docs officielles
# always : fetch automatique si pattern inconnu
# ask : demande avant de fetch
# never : jamais de fetch externe
docs_fetch: ask
# Peers — autres machines avec un brain (optionnel)
# Utile pour le multi-instance (desktop ↔ laptop)
# peers:
# laptop:
# url: http://<IP>:7700
# active: true

View File

@@ -2,7 +2,7 @@
# Versionné dans le kernel. Schema + feature flags + registre agents. # Versionné dans le kernel. Schema + feature flags + registre agents.
# Géré par l'agent brain-compose — ne pas éditer manuellement. # Géré par l'agent brain-compose — ne pas éditer manuellement.
version: "0.7.0" version: "0.9.0"
# --- # ---
# Ownership — kerneluser # Ownership — kerneluser
@@ -11,6 +11,34 @@ version: "0.7.0"
# Défaut : true sur tout brain forké (l'owner est toujours kerneluser) # Défaut : true sur tout brain forké (l'owner est toujours kerneluser)
# --- # ---
kerneluser: true kerneluser: true
identityShow: on # conséquence de kerneluser: true — présence visuelle complète des agents
# kerneluser: false → identityShow: off (mode clean/pro — BaaS client)
# ---
# Brain API Key — accès kernel + tiers (optionnel)
# ⚠️ La VRAIE clé va dans brain-compose.local.yml (gitignored) sous instances.<name>.brain_api_key
# Ce champ reste null ici — jamais commiter une vraie clé dans brain-compose.yml
# Absent ou null → tier: free (jamais d'erreur, jamais de blocage)
# Format prod : bk_live_<32chars>
# Format dev : bk_test_<32chars> (tier: free forcé côté serveur, toujours valide)
# Validation : key-guardian au boot → lit local.yml → valide → écrit feature_set dans local.yml
# ---
brain_api_key: null # toujours null ici — clé réelle dans brain-compose.local.yml
# ---
# feature_set schema — objet écrit par key-guardian après validation
# Stocké dans brain-compose.local.yml (non versionné) pour éviter les commits de clé
# Structure contractuelle : ne pas modifier manuellement
# ---
feature_set_schema:
tier: free # free | featured | pro | full
agents: [] # liste des agents autorisés ([] = feature_set.free)
contexts: [] # manifests BHP autorisés ([] = accès libre sur free)
distillation: false # true = brain-engine distillation locale autorisée (featured+)
catalog_version: "1.0.0" # version du CATALOG.yml agents — sync brain-store
last_validated_at: null # ISO 8601 — dernière validation réussie
expires_at: null # ISO 8601 — expiration clé (null = pas d'expiration fixe)
grace_until: null # ISO 8601 — VPS unreachable → grace 72h avant downgrade
# --- # ---
# Modes — comportement de session (permissions BSI + agents autorisés) # Modes — comportement de session (permissions BSI + agents autorisés)
@@ -166,12 +194,12 @@ modes:
contexte: false contexte: false
reference: read reference: read
personnel: false personnel: false
brain_write: false # pas d'écriture brain/ — uniquement le repo projet brain_write: false
forge: false forge: false
scope_lock: true # BLOQUÉ hors du scope déclaré dans le claim scope_lock: true
zone_lock: project # zone:kernel → BLOCKED_ON immédiat, pas de négociation zone_lock: project
circuit_breaker: circuit_breaker:
max_consecutive_fails: 3 # 3 échecs → arrêt + signal BLOCKED_ON vers pilote max_consecutive_fails: 3
on_trigger: "signal → BLOCKED_ON pilote" on_trigger: "signal → BLOCKED_ON pilote"
agents: [code-review, security, testing, debug, vps, ci-cd, pm2, migration] agents: [code-review, security, testing, debug, vps, ci-cd, pm2, migration]
behavior: | behavior: |
@@ -217,19 +245,29 @@ detectmode:
mode: coach mode: coach
- bsi_claim: HANDOFF - bsi_claim: HANDOFF
mode: HANDOFF mode: HANDOFF
default: prod default: prod # mode permissions par défaut — session type par défaut = navigate (ADR-044)
# --- # ---
# Feature sets — contrôlent les agents invocables par instance # Feature sets — contrôlent les agents invocables par instance
# Les agents "bloqués" existent dans le kernel, brain-compose contrôle l'accès. # Les agents "bloqués" existent dans le kernel, brain-compose contrôle l'accès.
# Chaîne : free → featured → pro → full
# --- # ---
feature_sets: feature_sets:
free: free:
description: "Agents fondamentaux — exploration et maintenance brain" description: "Agents fondamentaux — exploration et maintenance brain"
coach_level: boot # coach-boot.md — présence légère
sessions:
- navigate
- work
- debug
- brainstorm
- brain
- handoff
agents: agents:
- coach - coach-boot
- brain-guardian
- scribe - scribe
- todo-scribe - todo-scribe
- debug - debug
@@ -242,11 +280,40 @@ feature_sets:
- orchestrator-scribe - orchestrator-scribe
- recruiter - recruiter
- agent-review - agent-review
- time-anchor
- pattern-scribe
featured:
description: "Progression personnelle — RAG + distillation pour apprendre avec un brain qui connaît l'utilisateur"
extends: free
coach_level: full # coach.md complet — proposition de valeur centrale
distillation: true # RAG actif — le brain apprend et se souvient
sessions:
extends: free
- coach
- capital
agents:
- coach # coach.md full — remplace coach-boot en featured+
- coach-scribe
- capital-scribe
- progression-scribe
# Pas d'agents dev (code-review, security, vps, etc.)
# Use case : apprendre avec un brain qui te connaît — non-dev bienvenu
pro: pro:
description: "Agents métier — développement complet" description: "Agents métier — développement complet + coaching full"
extends: free extends: featured
coach_level: full
sessions:
extends: featured
- audit
- deploy
- infra
- urgence
- refacto
- migration
agents: agents:
- coach # coach.md full — remplace coach-boot en pro+
- code-review - code-review
- security - security
- testing - testing
@@ -269,10 +336,15 @@ feature_sets:
- mail - mail
- brain-compose - brain-compose
- config-scribe - config-scribe
- audit
- brain-state-bot
full: full:
description: "Accès complet — usage personnel sans restriction" description: "Accès complet — owner, usage personnel sans restriction + distillation"
extends: pro extends: pro
coach_level: L2 # coach.md + BACT + milestones long terme
sessions: "*" # inclut kernel + edit-brain — owner uniquement
distillation: true
agents: "*" agents: "*"
# --- # ---
@@ -290,19 +362,25 @@ changelog:
notes: "BSI (BRAIN-INDEX.md), brain_name, brain-template, aside, brainstorm, brain-compose up" notes: "BSI (BRAIN-INDEX.md), brain_name, brain-template, aside, brainstorm, brain-compose up"
- version: "0.3.0" - version: "0.3.0"
date: "2026-03-14" date: "2026-03-14"
notes: "orchestrator-scribe (free), brain-compose+config-scribe (pro), CHECKPOINT signal, session-as-identity, orchestration-patterns" notes: "orchestrator-scribe (free), brain-compose+config-scribe (pro), CHECKPOINT signal"
- version: "0.4.0" - version: "0.4.0"
date: "2026-03-14" date: "2026-03-14"
notes: "Système de modes — 11 modes, permissions BSI par mode, detectmode, toolkit-only autonome avec docs_fetch" notes: "Système de modes — 11 modes, permissions BSI par mode, detectmode"
- version: "0.5.0" - version: "0.5.0"
date: "2026-03-14" date: "2026-03-14"
notes: "Multi-sessions BSI v1.2 — CHECKPOINT/HANDOFF signals + handoff files ; brain-watch-vps daemon (stale TTL check, Telegram notifications) ; brain-bot Telegram webhook (/status /sessions /focus /help) ; workspace spec v1.0 (ram.md log.md feedback.md) ; supervisor patterns v1 (7 protocoles) ; statusline session-role ; secrets-guardian recovery protocol ; BLOCKED_ON false-positive fix" notes: "Multi-sessions BSI v1.2 — CHECKPOINT/HANDOFF, brain-bot Telegram, workspace spec v1.0"
- version: "0.5.1" - version: "0.5.1"
date: "2026-03-14" date: "2026-03-14"
notes: "Métabolisme v1 — mode conserve, metabolism-scribe, metabolism-spec, progression/metabolism/, helloWorld briefing métabolisme" notes: "Métabolisme v1 — mode conserve, metabolism-scribe, metabolism-spec"
- version: "0.6.0" - version: "0.6.0"
date: "2026-03-15" date: "2026-03-15"
notes: "Constitution v1.1.0 — Section 9 North Star + invariants autonomie + auto-amélioration (ADR-011) ; wiki/concepts.md fondamentaux brain V2 ; brain-engine vision north star" notes: "Constitution v1.1.0 — North Star + invariants autonomie"
- version: "0.7.0" - version: "0.7.0"
date: "2026-03-16" date: "2026-03-16"
notes: "BSI-v3 fondations — tiered-close, zone-aware claims (ADR-014), result contract, exit triggers ; kerneluser: true ancré kernel ; KERNEL.md délégation human-only phase actuelle" notes: "BSI-v3 fondations — tiered-close, zone-aware claims, kerneluser ancré"
- version: "0.8.0"
date: "2026-03-17"
notes: "Brain API Key Phase 1 — brain_api_key optionnel, feature_set_schema contractuel, tiers free/pro/full"
- version: "0.9.0"
date: "2026-03-20"
notes: "Tier featured ajouté (RAG + coaching complet), sessions par tier, coach_level par tier, identityShow, docs/ 14 pages, BHP Phase 2 (boot-summary/detail 16 agents)"

77
brain-engine/README.md Normal file
View File

@@ -0,0 +1,77 @@
---
name: brain-engine
type: reference
context_tier: cold
---
# brain-engine — Moteur local
> Le cerveau du brain. Recherche semantique, API locale, embeddings, BSI.
---
## Demarrage rapide
```bash
bash brain-engine/start.sh
```
Ca fait tout : installe les deps Python, cree brain.db, indexe le corpus si Ollama est present, et lance le serveur sur le port 7700.
---
## Prerequis
- **Python 3.10+** — `sudo apt install python3 python3-pip python3-venv`
- **Ollama** (optionnel mais recommande) — `curl -fsSL https://ollama.com/install.sh | sh`
- Modele embedding : `ollama pull nomic-embed-text`
- Sans Ollama : le serveur tourne mais la recherche semantique n'est pas disponible
---
## Architecture
```
brain-engine/
start.sh <- script de demarrage standalone
server.py <- API HTTP (FastAPI, port 7700)
mcp_server.py <- MCP server (FastMCP, port 7701)
embed.py <- pipeline embeddings (Ollama + nomic-embed-text)
search.py <- recherche cosine similarity + filtre scope
rag.py <- couche RAG (boot queries + ad-hoc)
schema.sql <- tables SQLite (claims, signals, embeddings, sessions)
migrate.py <- migration brain.db
distill.py <- distillation session memory (featured+)
requirements.txt <- dependances Python
```
---
## Endpoints principaux
- `GET /health` — statut du serveur
- `GET /search?q=` — recherche semantique dans le brain
- `GET /agents` — liste des agents disponibles
- `GET /boot` — contexte initial pour une session
- `GET /workflows` — claims BSI ouverts
- `GET /tier` — tier actif
---
## Mode standalone
Sans token configure, le serveur donne acces total en localhost. C'est le mode par defaut quand tu forkes le brain.
Sans cle API (`brain_api_key: null`), le tier est `free` — toutes les fonctionnalites fondamentales sont disponibles.
---
## Connexion Claude Code (MCP)
```bash
# Lancer le MCP server
python3 brain-engine/mcp_server.py
# Ajouter dans Claude Code
claude mcp add brain --transport http http://localhost:7701/mcp/
```

401
brain-engine/distill.py Normal file
View File

@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
brain-engine/distill.py — BE-5 Session memory distillation
Distille une session BSI (.jsonl Claude) en chunks indexés dans brain.db.
Usage :
python3 brain-engine/distill.py <session.jsonl> → distille la session
python3 brain-engine/distill.py <session.jsonl> --dry-run → aperçu sans écriture
python3 brain-engine/distill.py --last → distille la dernière session Claude
Point de substitution LLM : fonction summarize() — Ollama local (pro tier).
Pour tier full : remplacer summarize() par un appel API Claude/OpenAI.
Scope : work — les distillats sont accessibles via brain_search (MCP + owner).
"""
import os
import sys
import json
import re
import argparse
import sqlite3
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from embed import connect, upsert_chunk, get_embedding, chunk_id, OLLAMA_URL
# ── Config ─────────────────────────────────────────────────────────────────────
BRAIN_ROOT = Path(__file__).parent.parent
DISTILL_MODEL = os.getenv('DISTILL_MODEL', 'mistral:7b') # LLM local pour résumé
SCOPE = 'work'
# Sessions Claude — chemin par défaut
CLAUDE_SESSIONS_DIR = Path.home() / '.claude' / 'projects'
# Taille max du contexte envoyé au LLM (chars) — réduit pour garder le format few-shot (BE-5d)
MAX_CONTEXT_CHARS = 12_000
# Max messages récents envoyés au LLM — évite les narratives anglaises sur grandes sessions (BE-5d)
MAX_MESSAGES = 50
# Seuil minimum — sessions trop courtes ne contiennent que le brief, pas de vraies décisions (BE-5d)
MIN_MESSAGES = 10
# Levier 2 — max chunks par aspect (Stratégie A, split post-LLM)
CHUNK_LIMITS = {'decisions': 10, 'code': 5, 'todos': 5}
# ── Extraction session ─────────────────────────────────────────────────────────
def extract_messages(jsonl_path: Path) -> list[dict]:
"""Extrait les messages human/assistant du .jsonl Claude."""
messages = []
try:
with open(jsonl_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
msg = entry.get('message', {})
role = msg.get('role')
if role not in ('user', 'assistant'):
continue
content = msg.get('content', '')
if isinstance(content, list):
# Extraire le texte des blocs content
parts = [b.get('text', '') for b in content
if isinstance(b, dict) and b.get('type') == 'text']
content = '\n'.join(parts)
if content and content.strip():
messages.append({'role': role, 'content': content.strip()})
except FileNotFoundError:
sys.exit(f'❌ Fichier introuvable : {jsonl_path}')
return messages
def build_context(messages: list[dict], max_chars: int = MAX_CONTEXT_CHARS) -> str:
"""Construit un contexte tronqué pour le LLM.
Priorise les N derniers messages (MAX_MESSAGES) pour garder le LLM dans le format few-shot.
"""
# Bug 2 fix — prioriser les messages récents sur grandes sessions
if len(messages) > MAX_MESSAGES:
messages = messages[-MAX_MESSAGES:]
lines = []
total = 0
# On prend les messages les plus récents en priorité
for msg in reversed(messages):
prefix = 'USER' if msg['role'] == 'user' else 'ASSISTANT'
line = f'[{prefix}] {msg["content"][:500]}'
if total + len(line) > max_chars:
break
lines.append(line)
total += len(line)
lines.reverse()
return '\n\n'.join(lines)
# ── LLM — point de substitution ───────────────────────────────────────────────
def summarize(context: str, aspect: str) -> str | None:
"""
Résume le contexte selon l'aspect demandé.
POINT DE SUBSTITUTION : remplacer par API Claude/OpenAI pour tier full.
aspect : 'decisions' | 'code' | 'todos'
"""
prompts = {
'decisions': (
'Tu es un extracteur de mémoire technique. '
'Extrait les décisions architecturales et techniques prises dans cette session.\n\n'
'FORMAT OBLIGATOIRE : une décision par ligne, commençant par "- ".\n'
'Si aucune décision : répondre uniquement "none".\n\n'
'EXEMPLES :\n'
'Session : "On a choisi mistral:7b parce que mistral-small était trop lent"\n'
'\n'
'- Modèle LLM distillation : mistral:7b retenu (mistral-small écarté — latence)\n\n'
'Session : "On garde 3 chunks par session, max 10 decisions, 5 code, 5 todos"\n'
'\n'
'- Chunking BE-5 : 3 aspects (decisions/code/todos), caps 10/5/5\n\n'
'Session : "Finalement on utilise SQLite plutôt que Postgres pour brain.db"\n'
'\n'
'- Stockage brain.db : SQLite retenu (Postgres écarté — overhead opérationnel)\n\n'
'Réponds dans la même langue que la session. Max 15 mots par bullet.\n\n'
'Session :\n'
),
'code': (
'Tu es un extracteur de mémoire technique. '
'Extrait les fichiers créés ou modifiés, les fonctions clés implémentées, et les bugs corrigés.\n\n'
'FORMAT OBLIGATOIRE : une entrée par ligne, commençant par "- ".\n'
'Si rien de notable : répondre uniquement "none".\n\n'
'EXEMPLES :\n'
'Session : "On a créé distill.py avec les fonctions extract_messages, build_context et summarize"\n'
'\n'
'- brain-engine/distill.py créé — pipeline distillation : extract_messages(), build_context(), summarize()\n\n'
'Session : "J\'ai corrigé le timeout dans embed.py, maintenant c\'est 90s au lieu de 60s"\n'
'\n'
'- embed.py:get_embedding() — fix timeout 60s → 90s\n\n'
'Session : "On a ajouté CHUNK_LIMITS et parse_bullets dans distill.py"\n'
'\n'
'- distill.py — ajout CHUNK_LIMITS (10/5/5) + parse_bullets() stratégie A\n\n'
'Réponds dans la même langue que la session. Sois concis.\n\n'
'Session :\n'
),
'todos': (
'Tu es un extracteur de mémoire technique. '
'Extrait les tâches ouvertes, blockers et prochaines étapes mentionnés dans cette session.\n\n'
'FORMAT OBLIGATOIRE : une tâche par ligne, commençant par "- ".\n'
'Si aucune tâche : répondre uniquement "none".\n\n'
'EXEMPLES :\n'
'Session : "Il faudra tester deepseek-coder pour l\'aspect code plus tard"\n'
'\n'
'- Tester deepseek-coder:6.7b pour aspect "code" (levier 3 BE-5)\n\n'
'Session : "Le cron VPS n\'est pas viable tant qu\'Ollama ne tourne pas sur le VPS"\n'
'\n'
'- Installer Ollama sur VPS pour activer cron distillation automatique\n\n'
'Session : "On fera l\'externalisation des prompts en BE-5c si nécessaire"\n'
'\n'
'- BE-5c (optionnel) : externaliser prompts distill dans brain-engine/prompts/*.txt\n\n'
'Réponds dans la même langue que la session. Sois concis.\n\n'
'Session :\n'
),
}
prompt = prompts[aspect] + context
url = f'{OLLAMA_URL}/api/generate'
payload = json.dumps({
'model': DISTILL_MODEL,
'prompt': prompt,
'stream': False,
'options': {'temperature': 0.1, 'num_predict': 400},
}).encode()
req = urllib.request.Request(url, data=payload,
headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
return data.get('response', '').strip()
except (urllib.error.URLError, TimeoutError) as e:
print(f'⚠️ Ollama indisponible ({OLLAMA_URL}) : {e}', file=sys.stderr)
return None
# ── Parsing bullets (Stratégie A — post-split) ────────────────────────────────
def parse_bullets(text: str) -> list[str]:
"""
Extrait les bullets d'une réponse LLM.
Reconnaît : '- ', '', '* ', ' ' en début de ligne.
Gère les continuations (ligne indentée sans préfixe = suite du bullet précédent).
"""
bullets: list[str] = []
current: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
# Préfixes reconnus : tiret court, puce, astérisque, tiret long
is_bullet = (
stripped[:2] in ('- ', '', '* ')
or (stripped[0] == '' and len(stripped) > 1 and stripped[1] == ' ')
)
if is_bullet:
if current:
bullets.append(' '.join(current))
# Extraire le texte après le préfixe (1 ou 2 chars)
prefix_len = 2 if stripped[:2] in ('- ', '', '* ') else 2
current = [stripped[prefix_len:].strip()]
elif current:
# Continuation d'un bullet multi-ligne
current.append(stripped)
if current:
bullets.append(' '.join(current))
return [b for b in bullets if b]
# ── Summarisation 2 passes (BE-5e) ────────────────────────────────────────────
def summarize_2pass(messages: list[dict], aspect: str) -> str | None:
"""
Summarisation en 2 passes pour grandes sessions (BE-5e).
Pass 1 : résumé de chaque bloc de MAX_MESSAGES messages.
Pass 2 : résumé final sur la concaténation des résumés partiels.
"""
blocks = [messages[i:i + MAX_MESSAGES] for i in range(0, len(messages), MAX_MESSAGES)]
partial_summaries = []
for idx, block in enumerate(blocks):
context = build_context(block)
partial = summarize(context, aspect)
if partial and partial.strip().lower() not in ('none', 'aucune', 'aucun', 'ninguno', 'ninguna', ''):
partial_summaries.append(f'# Bloc {idx + 1}/{len(blocks)}\n{partial}')
if not partial_summaries:
return None
combined = '\n\n'.join(partial_summaries)
# Pass 2 : résumé final
return summarize(combined[:MAX_CONTEXT_CHARS], aspect)
# ── Distillation ──────────────────────────────────────────────────────────────
def distill_session(jsonl_path: Path, dry_run: bool = False) -> int:
"""
Distille une session en chunks granulaires (1 bullet = 1 chunk).
Caps : decisions ≤ 10, code ≤ 5, todos ≤ 5.
Retourne le nombre de chunks indexés.
"""
print(f'📖 Lecture : {jsonl_path.name}')
messages = extract_messages(jsonl_path)
if not messages:
print('⚠️ Aucun message extractible — session vide ou format inconnu.')
return 0
print(f' {len(messages)} messages extraits')
# Bug 1 fix — filtre micro-sessions (brief bootstrap seul, pas de vraies décisions)
if len(messages) < MIN_MESSAGES:
print(f'⚠️ Session trop courte ({len(messages)} messages < {MIN_MESSAGES}) — skip.')
return 0
is_large = len(messages) > MAX_MESSAGES
context = build_context(messages) if not is_large else None
if is_large:
print(f' ⚡ Grande session ({len(messages)} msg) — mode 2-pass activé')
sess_id = jsonl_path.stem # ex: c22807f5-04df-...
date_str = datetime.now().strftime('%Y-%m-%d')
conn = connect() if not dry_run else None
total = 0
# Bug 3 fix — purger les anciens chunks sans suffixe numérique (format pré-BE-5b)
if conn:
cur = conn.cursor()
cur.execute(
'DELETE FROM embeddings WHERE filepath LIKE ? AND filepath NOT LIKE ?',
(f'sessions/{sess_id}/%', f'sessions/{sess_id}/%/%'),
)
purged = cur.rowcount
if purged:
print(f' 🧹 {purged} anciens chunk(s) purgés (format pré-BE-5b)')
conn.commit()
for aspect in ('decisions', 'code', 'todos'):
limit = CHUNK_LIMITS[aspect]
if is_large:
print(f' 🧠 Distillation [{aspect}] (2-pass)...', end=' ', flush=True)
summary = summarize_2pass(messages, aspect)
else:
print(f' 🧠 Distillation [{aspect}]...', end=' ', flush=True)
summary = summarize(context, aspect)
if not summary or summary.strip().lower() in ('aucune', 'aucun', 'none', 'ninguno', 'ninguna', ''):
print('vide — ignoré')
continue
bullets = parse_bullets(summary)
if not bullets:
# Fallback : LLM n'a pas suivi le format — 1 chunk brut plutôt que perdre l'info
bullets = [summary.strip()]
# Filtrer les bullets "none" parasites (LLM met parfois "none:" au lieu du sentinel)
_none_words = {'none', 'aucune', 'aucun', 'ninguno', 'ninguna'}
bullets = [b for b in bullets
if b.strip().lower().split()[0].rstrip(':') not in _none_words]
bullets = bullets[:limit]
print(f'{len(bullets)} bullet(s)')
for i, bullet in enumerate(bullets):
filepath = f'sessions/{sess_id}/{aspect}/{i:02d}'
title = f'Session {date_str}{aspect} #{i+1:02d}'
chunk = {
'filepath': filepath,
'title': title,
'text': f'# {title}\n\nSource : {jsonl_path.name}\n\n- {bullet}',
'scope': SCOPE,
}
if dry_run:
print(f' [{aspect}/{i:02d}] {bullet[:100]}')
total += 1
continue
vector = get_embedding(chunk['text'])
if vector:
upsert_chunk(conn, chunk, vector)
conn.commit()
total += 1
else:
print(f'⚠️ embed échoué [{aspect}/{i:02d}] — stocké sans vecteur')
upsert_chunk(conn, chunk, None)
conn.commit()
if conn:
conn.close()
return total
# ── Helpers ───────────────────────────────────────────────────────────────────
def find_last_session() -> Path | None:
"""Trouve le .jsonl de la dernière session Claude dans ~/.claude/projects."""
jsonl_files = list(CLAUDE_SESSIONS_DIR.glob('**/*.jsonl'))
if not jsonl_files:
return None
return max(jsonl_files, key=lambda p: p.stat().st_mtime)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description='brain-engine distill — BE-5 session memory distillation'
)
parser.add_argument('session', nargs='?', type=Path,
help='Chemin vers le .jsonl de session Claude')
parser.add_argument('--last', action='store_true',
help='Distille la dernière session Claude automatiquement')
parser.add_argument('--dry-run', action='store_true',
help='Aperçu sans écriture dans brain.db')
args = parser.parse_args()
if args.last:
jsonl = find_last_session()
if not jsonl:
sys.exit('❌ Aucune session trouvée dans ~/.claude/projects/')
print(f'📌 Dernière session : {jsonl}')
elif args.session:
jsonl = args.session
else:
parser.print_help()
sys.exit(1)
mode = ' (dry-run)' if args.dry_run else ''
print(f'\n🔬 Distillation BE-5{mode}\n')
n = distill_session(jsonl, dry_run=args.dry_run)
if n == 0:
print('\n⚠️ Aucun chunk produit — session vide ou Ollama indisponible.')
sys.exit(2)
print(f'\n{n} chunk(s) distillé(s) → brain.db (scope: {SCOPE})')
if not args.dry_run:
print(' → brain_search "session précédente" pour retrouver ce contexte')
if __name__ == '__main__':
main()

524
brain-engine/embed.py Normal file
View File

@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""
brain-engine/embed.py — Pipeline d'embedding BE-2c
Indexe le corpus brain via Ollama nomic-embed-text → table embeddings dans brain.db
Usage :
python3 brain-engine/embed.py → index tout le corpus
python3 brain-engine/embed.py --dry-run → liste les chunks sans embed
python3 brain-engine/embed.py --file agents/helloWorld.md → réindexer un fichier
python3 brain-engine/embed.py --stats → stats de l'index actuel
Headless : zéro dépendance Wayland/display.
OLLAMA_URL : variable d'env (défaut localhost:11434) — supporte réseau local.
Zone filter — ADR-033a (2026-03-18) :
kernel (agents/, wiki/, toolkit/, contexts/, KERNEL.md) → toujours indexé
project (projets/, handoffs/, workspace/) → TTL 60 jours git-based
session (claims/) → JAMAIS indexé
personal (profil/bact/, profil/collaboration.md) → JAMAIS indexé
profil/decisions/ → scope frontmatter (kernel | project)
Stratégie chunking par type :
agents/*.md, projets/*.md, wiki/**/*.md → chunk par section H2
workspace/**/*.md, profil/decisions/*.md → H2 ou fichier entier si < 512 tokens
KERNEL.md, focus.md, contexts/ → fichier entier (documents courts)
"""
import os
import re
import sys
import json
import struct
import hashlib
import argparse
import sqlite3
import subprocess
import time
import urllib.request
import urllib.error
from datetime import datetime
from pathlib import Path
BRAIN_ROOT = Path(__file__).parent.parent
DB_PATH = BRAIN_ROOT / 'brain.db'
OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://localhost:11434')
EMBED_MODEL = os.getenv('EMBED_MODEL', 'nomic-embed-text')
# Guardrail — LLMs génériques interdits : freeze machine garanti sur corpus entier
# (validé empiriquement : mistral:7b + qwen3:8b → freeze total ~20min, 2026-03-16)
_BLOCKED_MODELS = ['mistral', 'qwen', 'llama', 'gemma', 'phi', 'deepseek']
if any(b in EMBED_MODEL.lower() for b in _BLOCKED_MODELS):
sys.exit(f"❌ EMBED_MODEL='{EMBED_MODEL}' interdit — LLM générique → freeze machine sur corpus entier.\n"
f" Utiliser un modèle dédié embedding : nomic-embed-text, mxbai-embed-large, all-minilm")
CHUNK_TOKENS = 512 # tokens max par chunk (approximé : 1 token ≈ 4 chars)
CHUNK_OVERLAP = 64 # overlap entre chunks consécutifs
# ── Zones d'accès ─────────────────────────────────────────────────────────────
# Zone 0 — jamais indexé (privé absolu) — ADR-033a
PRIVATE_PATHS = [
'profil/capital.md',
'profil/objectifs.md',
'profil/bact/', # personal — jamais
'profil/collaboration.md',# personal — jamais
'progression/', # personal — journal + tout le répertoire
'MYSECRETS',
]
# Zone par préfixe — premier match gagne — ADR-033a + KERNEL.md zones
# Zones : kernel | instance | satellite | public (private = exclusion totale ci-dessus)
PATH_SCOPES = [
# KERNEL — protection maximale
('contexts/', 'kernel'),
('profil/decisions/', 'kernel'),
('profil/', 'kernel'),
('KERNEL.md', 'kernel'),
('brain-constitution.md', 'kernel'),
('scripts/', 'kernel'),
# INSTANCE — configuration machine + projets actifs
('focus.md', 'instance'),
('projets/', 'instance'),
('PATHS.md', 'instance'),
('now.md', 'instance'),
# SATELLITE — vie libre, promotion possible
('toolkit/', 'satellite'),
('todo/', 'satellite'),
('workspace/', 'satellite'),
('handoffs/', 'satellite'),
('intentions/', 'satellite'),
# PUBLIC — visible, distribué
('wiki/', 'public'),
('agents/', 'public'),
('infrastructure/', 'public'),
('BRAIN-INDEX.md', 'public'),
]
DEFAULT_SCOPE = 'public'
TTL_PROJECT_DAYS = 60 # ADR-033a — TTL projet, git-based
def is_private(filepath: str) -> bool:
"""Zone 0 — jamais indexé, jamais accessible."""
return any(filepath == p or filepath.startswith(p) for p in PRIVATE_PATHS)
def resolve_scope(filepath: str) -> str:
"""Retourne la zone d'accès (kernel | instance | satellite | public)."""
for prefix, scope in PATH_SCOPES:
if filepath == prefix or filepath.startswith(prefix):
return scope
return DEFAULT_SCOPE
def get_frontmatter_scope(filepath: Path) -> str | None:
"""
Lit le champ scope: du frontmatter YAML d'un fichier .md.
Retourne 'kernel' | 'project' | 'personal' | None si absent.
ADR-033a Règle 2 — override sur la règle répertoire.
"""
try:
text = filepath.read_text(errors='replace')
if not text.startswith('---'):
return None
end = text.find('\n---', 3)
if end == -1:
return None
for line in text[3:end].splitlines():
line = line.strip()
if line.startswith('scope:'):
val = line[len('scope:'):].strip()
val = val.split('#')[0].strip() # retire commentaires inline
return val if val else None
except Exception:
pass
return None
def get_git_age_days(filepath: Path) -> int | None:
"""
Retourne le nombre de jours depuis le dernier git commit sur ce fichier.
None si le fichier n'est pas tracké ou si git échoue.
ADR-033a — TTL git-based, aucun couplage BSI.
"""
try:
result = subprocess.run(
['git', 'log', '-1', '--format=%ct', '--', str(filepath)],
capture_output=True, text=True, cwd=str(BRAIN_ROOT), timeout=5
)
ts = result.stdout.strip()
if not ts:
return None
age_secs = time.time() - int(ts)
return int(age_secs / 86400)
except Exception:
return None
def should_skip_by_zone(filepath: Path) -> bool:
"""
Applique les règles ADR-033a — retourne True si le fichier doit être exclu.
Règle 1 — répertoire (défaut)
Règle 2 — frontmatter scope: (override sur Règle 1, pour profil/decisions/)
Zones :
kernel → False (toujours indexé)
project + TTL > 60j → True (périmé)
personal → True (jamais)
"""
rel = str(filepath.relative_to(BRAIN_ROOT))
# profil/decisions/ — Règle 2 : scope par frontmatter
if rel.startswith('profil/decisions/'):
scope = get_frontmatter_scope(filepath)
if scope == 'personal':
return True
if scope == 'project':
age = get_git_age_days(filepath)
return age is not None and age > TTL_PROJECT_DAYS
# scope: kernel ou absent → toujours indexé
return False
# Zone project — TTL git-based
if any(rel.startswith(p) for p in ('projets/', 'handoffs/', 'workspace/')):
age = get_git_age_days(filepath)
return age is not None and age > TTL_PROJECT_DAYS
return False
# Corpus à indexer — chemins relatifs à BRAIN_ROOT — ADR-033a
# kernel → toujours | project → TTL 60j git | omis → JAMAIS
CORPUS_PATHS = [
# ── kernel — toujours indexé ──────────────────────────────────────────────
('agents', '*.md', 'h2'), # agents brain
('wiki', '**/*.md', 'h2'), # documentation (submodule)
('toolkit', '**/*.md', 'h2'), # patterns réutilisables
('contexts', '*.yml', 'file'), # contextes de session
# ── project — TTL 60 jours git-based ─────────────────────────────────────
('projets', '*.md', 'h2'),
('handoffs', '*.md', 'file'),
('workspace', '**/*.md', 'h2'),
# ── profil/decisions — scope par frontmatter (kernel | project) ──────────
('profil/decisions', '*.md', 'file'),
# ── fichiers racine kernel ────────────────────────────────────────────────
('.', 'KERNEL.md', 'file'),
('.', 'focus.md', 'file'),
('.', 'BRAIN-INDEX.md', 'file'),
# SUPPRIMÉ : ('ADR', ...) — chemin obsolète (ADRs dans profil/decisions/)
# SUPPRIMÉ : ('profil', ...) — trop large, inclut bact/ — géré par scope
# SUPPRIMÉ : ('claims', ...) — JAMAIS indexé per ADR-033a (session structurée)
]
# Fichiers à exclure
EXCLUDE_PATTERNS = [
'brain-template/',
'brain-engine/',
'.git/',
'node_modules/',
]
# ── Helpers ───────────────────────────────────────────────────────────────────
def should_exclude(filepath: Path) -> bool:
s = str(filepath)
if any(p in s for p in EXCLUDE_PATTERNS):
return True
# Zone 0 — privé absolu, jamais indexé
if filepath.is_absolute():
try:
rel = str(filepath.relative_to(BRAIN_ROOT))
except ValueError:
rel = s # path hors BRAIN_ROOT — is_private unlikely mais safe
else:
rel = s
return is_private(rel)
def chunk_by_h2(text: str, filepath: str) -> list[dict]:
"""Découpe un markdown en chunks par section H2."""
sections = re.split(r'\n(?=## )', text)
chunks = []
for sec in sections:
sec = sec.strip()
if not sec:
continue
# Si section trop longue → re-découper par paragraphes
if len(sec) > CHUNK_TOKENS * 4:
sub = chunk_by_size(sec, filepath)
chunks.extend(sub)
else:
title = sec.split('\n')[0].strip('#').strip()
chunks.append({'text': sec, 'title': title, 'filepath': filepath})
return chunks if chunks else [{'text': text, 'title': '', 'filepath': filepath}]
def chunk_by_size(text: str, filepath: str) -> list[dict]:
"""Découpe un texte en chunks de CHUNK_TOKENS tokens (approx)."""
max_chars = CHUNK_TOKENS * 4
overlap_chars = CHUNK_OVERLAP * 4
chunks = []
start = 0
while start < len(text):
end = min(start + max_chars, len(text))
# Couper sur un saut de ligne si possible
if end < len(text):
nl = text.rfind('\n', start, end)
if nl > start:
end = nl
chunk_text = text[start:end].strip()
if chunk_text:
chunks.append({'text': chunk_text, 'title': '', 'filepath': filepath})
if end >= len(text):
break
# Toujours avancer : si l'overlap remonterait avant start, aller à end
next_start = end - overlap_chars
start = next_start if next_start > start else end
return chunks
def chunk_file(filepath: Path, strategy: str) -> list[dict]:
"""Lit un fichier et retourne ses chunks selon la stratégie."""
try:
text = filepath.read_text(errors='replace').strip()
except Exception as e:
print(f" ⚠️ {filepath.name} : erreur lecture — {e}")
return []
if not text:
return []
rel = str(filepath.relative_to(BRAIN_ROOT))
if strategy == 'h2':
return chunk_by_h2(text, rel)
else:
# Fichier entier — si trop long, chunk par taille
if len(text) > CHUNK_TOKENS * 4:
return chunk_by_size(text, rel)
title = filepath.stem
return [{'text': text, 'title': title, 'filepath': rel}]
def chunk_id(filepath: str, text: str) -> str:
"""ID déterministe : hash(filepath + text[:64])."""
h = hashlib.sha1(f"{filepath}::{text[:64]}".encode()).hexdigest()[:12]
return f"emb-{h}"
# ── Ollama API ────────────────────────────────────────────────────────────────
def get_embedding(text: str) -> list[float] | None:
"""Appelle Ollama embeddings API — retourne None si indisponible."""
url = f"{OLLAMA_URL}/api/embeddings"
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
req = urllib.request.Request(url, data=payload,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
return data.get('embedding')
except (urllib.error.URLError, TimeoutError) as e:
print(f" ⚠️ Ollama indisponible ({OLLAMA_URL}) : {e}")
return None
def vector_to_blob(vec: list[float]) -> bytes:
"""Sérialise un vecteur float32 en BLOB SQLite."""
return struct.pack(f'{len(vec)}f', *vec)
def blob_to_vector(blob: bytes) -> list[float]:
"""Désérialise un BLOB SQLite en vecteur float32."""
n = len(blob) // 4
return list(struct.unpack(f'{n}f', blob))
# ── SQLite ────────────────────────────────────────────────────────────────────
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Créer la table embeddings si absente (extend schema)
conn.execute("""
CREATE TABLE IF NOT EXISTS embeddings (
chunk_id TEXT PRIMARY KEY,
filepath TEXT NOT NULL,
title TEXT,
chunk_text TEXT NOT NULL,
vector BLOB, -- NULL si Ollama indisponible au moment du chunk
model TEXT,
indexed INTEGER DEFAULT 0, -- 1 = vecteur présent
scope TEXT NOT NULL DEFAULT 'work', -- kernel | instance | satellite | public
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# Migration — ajouter scope si absente (db existante avant BE-4)
try:
conn.execute("ALTER TABLE embeddings ADD COLUMN scope TEXT NOT NULL DEFAULT 'work'")
conn.commit()
# Backfill — résoudre le scope de chaque chunk existant depuis son filepath
rows = conn.execute("SELECT DISTINCT filepath FROM embeddings WHERE scope = 'work'").fetchall()
for row in rows:
fp = row['filepath']
s = resolve_scope(fp)
if s != 'work':
conn.execute("UPDATE embeddings SET scope = ? WHERE filepath = ?", (s, fp))
conn.commit()
except Exception:
pass # colonne déjà présente
conn.execute("CREATE INDEX IF NOT EXISTS idx_emb_filepath ON embeddings(filepath)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_emb_indexed ON embeddings(indexed)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_emb_scope ON embeddings(scope)")
conn.commit()
return conn
def upsert_chunk(conn: sqlite3.Connection, chunk: dict,
vector: list[float] | None, dry_run: bool = False) -> bool:
cid = chunk_id(chunk['filepath'], chunk['text'])
blob = vector_to_blob(vector) if vector else None
indexed = 1 if vector else 0
scope = chunk.get('scope', resolve_scope(chunk['filepath']))
if dry_run:
return True
conn.execute("""
INSERT INTO embeddings(chunk_id, filepath, title, chunk_text, vector, model, indexed, scope, updated_at)
VALUES (?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(chunk_id) DO UPDATE SET
chunk_text = excluded.chunk_text,
vector = COALESCE(excluded.vector, embeddings.vector),
indexed = COALESCE(excluded.indexed, embeddings.indexed),
scope = excluded.scope,
updated_at = excluded.updated_at
""", (cid, chunk['filepath'], chunk.get('title',''), chunk['text'],
blob, EMBED_MODEL if vector else None, indexed, scope))
return True
# ── Pipeline principal ────────────────────────────────────────────────────────
def collect_files(target_file: str | None = None) -> list[tuple[Path, str]]:
"""Retourne la liste (path, strategy) des fichiers à indexer."""
files = []
seen = set()
if target_file:
p = (BRAIN_ROOT / target_file).resolve()
if not str(p).startswith(str(BRAIN_ROOT.resolve())):
print(f" 🚨 --file hors BRAIN_ROOT refusé : {p}")
return files
if p.exists():
# Déterminer stratégie par répertoire
for base, pattern, strategy in CORPUS_PATHS:
if str(p).startswith(str(BRAIN_ROOT / base)):
files.append((p, strategy))
break
else:
files.append((p, 'h2'))
return files
for base, pattern, strategy in CORPUS_PATHS:
base_path = BRAIN_ROOT / base
if not base_path.exists():
continue
for p in sorted(base_path.glob(pattern)):
if p in seen or not p.is_file():
continue
if should_exclude(p):
continue
if should_skip_by_zone(p):
continue
seen.add(p)
files.append((p, strategy))
return files
def run(dry_run: bool = False, target_file: str | None = None,
stats_only: bool = False):
conn = connect()
if stats_only:
total = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
indexed = conn.execute("SELECT COUNT(*) FROM embeddings WHERE indexed=1").fetchone()[0]
pending = total - indexed
files_n = conn.execute("SELECT COUNT(DISTINCT filepath) FROM embeddings").fetchone()[0]
print(f"Index embeddings :")
print(f" chunks total : {total}")
print(f" indexés : {indexed} ({100*indexed//total if total else 0}%)")
print(f" sans vecteur : {pending}")
print(f" fichiers : {files_n}")
print(f" modèle : {EMBED_MODEL} @ {OLLAMA_URL}")
conn.close()
return
files = collect_files(target_file)
print(f"Corpus : {len(files)} fichier(s) — modèle {EMBED_MODEL} @ {OLLAMA_URL}")
# Tester Ollama avant de boucler
test_vec = get_embedding("test connexion") if not dry_run else None
ollama_ok = test_vec is not None
if not ollama_ok and not dry_run:
print(f" ⚠️ Ollama indisponible — chunks enregistrés sans vecteur (indexed=0)")
total_chunks = 0
total_indexed = 0
for filepath, strategy in files:
chunks = chunk_file(filepath, strategy)
if not chunks:
continue
file_chunks = 0
for chunk in chunks:
chunk['scope'] = resolve_scope(chunk['filepath'])
vec = None
if ollama_ok and not dry_run:
vec = get_embedding(chunk['text'])
if vec:
total_indexed += 1
upsert_chunk(conn, chunk, vec, dry_run=dry_run)
total_chunks += 1
file_chunks += 1
rel = str(filepath.relative_to(BRAIN_ROOT))
status = "" if ollama_ok else ""
print(f" {status} {rel}{file_chunks} chunk(s)")
if not dry_run:
conn.commit()
print(f"\n{'[dry] ' if dry_run else ''}Chunks traités : {total_chunks}")
if not dry_run:
print(f"Vecteurs générés : {total_indexed}")
if not ollama_ok:
print(f"⚠️ Relancer avec Ollama actif pour compléter l'index")
conn.close()
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='brain-engine embed — pipeline embeddings BE-2c')
parser.add_argument('--dry-run', action='store_true', help='Liste les chunks sans embed')
parser.add_argument('--file', metavar='PATH', help='Réindexer un fichier spécifique')
parser.add_argument('--stats', action='store_true', help='Stats de l\'index actuel')
args = parser.parse_args()
run(dry_run=args.dry_run, target_file=args.file, stats_only=args.stats)
if __name__ == '__main__':
main()

412
brain-engine/mcp_server.py Normal file
View File

@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""
brain-engine/mcp_server.py — BE-4 MCP Server
Expose le brain comme source de contexte native pour Claude.
Transport : StreamableHTTP (MCP 1.x)
Port : 7701 (défaut) — distinct du BaaS HTTP (7700)
Auth : BRAIN_TOKEN_MCP dans MYSECRETS → passé via header x-api-key
Outils exposés :
brain_search(query, top) → recherche sémantique (zones public + work)
brain_boot() → contexte de boot (3 queries ciblées)
brain_workflows() → workflows actifs (claims BSI ouverts)
brain_agents(name) → liste des agents ou contenu d'un agent
brain_decisions(last) → dernières décisions architecturales (ADRs)
brain_focus() → focus actuel du brain (direction + projets + blockers)
brain_write(path, content)→ écrire un fichier dans le brain via PUT /brain/{path}
Usage :
python3 brain-engine/mcp_server.py → port 7701 (défaut)
BRAIN_MCP_PORT=8000 python3 brain-engine/mcp_server.py
Connexion Claude Code :
claude mcp add brain --transport http http://localhost:7701/mcp/
Auth dans Claude Code :
Settings → MCP → brain → Headers → x-api-key: <BRAIN_TOKEN_MCP>
"""
import os
import sys
import logging
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
sys.path.insert(0, str(Path(__file__).parent))
from rag import run_boot_queries, run_single_query, format_compact, format_full
# ── Config ─────────────────────────────────────────────────────────────────────
BRAIN_MCP_PORT = int(os.getenv('BRAIN_MCP_PORT', 7701))
BRAIN_TOKEN_MCP = os.getenv('BRAIN_TOKEN_MCP') or os.getenv('BRAIN_TOKEN')
# Scopes autorisés pour le token MCP
MCP_SCOPES = ['public', 'work']
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger('brain-mcp')
# ── MCP Server ─────────────────────────────────────────────────────────────────
mcp = FastMCP(
name='brain',
instructions=(
'Brain-as-a-Service — mémoire sémantique du brain. '
'Utilise brain_search pour trouver du contexte précis sur un sujet. '
'Utilise brain_boot au démarrage d\'une session pour charger le contexte actif. '
'Les résultats sont des chunks de fichiers markdown classés par pertinence. '
'Zones accessibles : focus, todos, projets, agents, infrastructure.'
),
)
# ── Auth middleware ─────────────────────────────────────────────────────────────
class BrainAuthMiddleware:
"""
Wrapper ASGI — vérifie x-api-key avant chaque requête MCP.
Note : les dunders Python (__call__) sont résolus sur la classe, pas l'instance.
Un vrai wrapper ASGI est requis (monkey-patch d'instance ne fonctionne pas).
"""
def __init__(self, app, token: str | None):
self._app = app
self._token = token
async def __call__(self, scope, receive, send):
if scope['type'] == 'http' and self._token:
headers = dict(scope.get('headers', []))
api_key = headers.get(b'x-api-key', b'').decode()
if api_key != self._token:
async def _send_401():
await send({'type': 'http.response.start', 'status': 401,
'headers': [(b'content-type', b'application/json')]})
await send({'type': 'http.response.body',
'body': b'{"error":"Unauthorized"}', 'more_body': False})
await _send_401()
return
await self._app(scope, receive, send)
mcp_app = BrainAuthMiddleware(mcp.streamable_http_app(), BRAIN_TOKEN_MCP)
# ── Outils MCP ─────────────────────────────────────────────────────────────────
@mcp.tool()
def brain_search(query: str, top: int = 5, full: bool = False) -> str:
"""
Recherche sémantique dans le brain.
Args:
query : Question en langage naturel (ex: "comment fonctionne le BSI v2 ?")
top : Nombre de résultats (défaut: 5, max recommandé: 10)
full : True = chunks complets, False = extraits 120 chars (défaut)
Returns:
Bloc markdown avec les chunks les plus pertinents, triés par score.
Chaque résultat indique le filepath source et un extrait du contenu.
"""
log.info('brain_search query=%r top=%d full=%s', query, top, full)
results = run_single_query(query, top_k=top, allowed_scopes=MCP_SCOPES)
if not results:
return f'Aucun résultat pour : {query!r}'
label = f'brain_search — {query}'
return format_full(results, label=label) if full else format_compact(results, label=label)
@mcp.tool()
def brain_state() -> str:
"""
Environnement fondamental du brain — dérivé en temps réel, jamais stocké.
Retourne les services actifs (pm2), la version brain (git), et les ports
configurés. Layer 2 uniquement (localhost).
À appeler en début de session pour connaître l'état de l'infrastructure
sans avoir à demander "quel port ? quel service tourne ?".
Returns:
Bloc markdown structuré avec hostname, version, pm2 status, ports.
"Indisponible" si brain-engine hors ligne.
"""
import json
import urllib.request
log.info('brain_state')
try:
with urllib.request.urlopen('http://127.0.0.1:7700/state', timeout=3) as resp:
data = json.loads(resp.read())
lines = [f'## Environnement fondamental\n']
lines.append(f"**Machine** : {data.get('hostname', '?')}")
lines.append(f"**Brain** : {data.get('brain_version', '?')}\n")
pm2 = data.get('pm2', [])
if pm2:
lines.append('**Services (pm2)**')
lines.append('| Nom | Status | Restarts |')
lines.append('|-----|--------|---------|')
for p in pm2:
icon = '🟢' if p.get('status') == 'online' else '🔴'
lines.append(f"| {p['name']} | {icon} {p.get('status','?')} | {p.get('restarts',0)} |")
ports = data.get('ports', {})
if ports:
lines.append(f"\n**Ports** : engine={ports.get('brain_engine','?')} · mcp={ports.get('brain_mcp','?')} · key={ports.get('brain_key','?')}")
return '\n'.join(lines)
except Exception as exc:
log.warning('brain_state failed: %s', exc)
return f'Environnement indisponible : {exc}'
@mcp.tool()
def brain_boot() -> str:
"""
Charge le contexte de boot du brain.
Séquence :
1. brain/now.md — slot garanti (push de la session précédente)
2. brain_state() — environnement fondamental dérivé (pm2, ports)
3. 3 queries RAG ciblées (décisions récentes, todos prioritaires, sprint actif)
À appeler en début de session pour enrichir le contexte sans saturer le
context window. Exit silencieux si Ollama indisponible.
Returns:
Bloc markdown additif avec contexte de boot complet.
"""
log.info('brain_boot')
sections = []
# 1. Slot garanti — brain/now.md
now_path = Path(__file__).parent.parent / 'brain' / 'now.md'
if now_path.exists():
try:
content = now_path.read_text(encoding='utf-8').strip()
if content:
sections.append(content)
except Exception:
pass
# 2. Environnement dérivé
env = brain_state()
if env and 'Indisponible' not in env:
sections.append(env)
# 3. RAG queries
results = run_boot_queries(allowed_scopes=MCP_SCOPES)
if results:
sections.append(format_compact(results, label='brain_boot'))
return '\n\n---\n\n'.join(sections) if sections else ''
@mcp.tool()
def brain_workflows() -> str:
"""
Retourne les workflows actifs du brain (claims BSI ouverts).
Returns:
Bloc markdown avec les workflows en cours : nom, projet, étapes, statuts.
Utile en début de session pour connaître l'état des sprints actifs.
"""
import json
import urllib.request
log.info('brain_workflows')
try:
url = f'http://127.0.0.1:7700/workflows'
with urllib.request.urlopen(url, timeout=3) as resp:
data = json.loads(resp.read())
workflows = data.get('workflows', [])
if not workflows:
return 'Aucun workflow actif.'
lines = ['## Workflows actifs\n']
for wf in workflows:
lines.append(f"### {wf.get('name', wf.get('id', '?'))}{wf.get('project', '')}")
for step in wf.get('steps', []):
status = step.get('status', '?')
icon = {'done': '', 'in-progress': '🔄', 'pending': '',
'gate': '🔶', 'blocked': '🔴', 'fail': ''}.get(status, '')
gate = ' [GATE]' if step.get('isGate') else ''
lines.append(f" {icon} {step.get('label', step.get('id', '?'))}{gate}")
lines.append('')
return '\n'.join(lines)
except Exception as exc:
log.warning('brain_workflows failed: %s', exc)
return f'Workflows indisponibles : {exc}'
@mcp.tool()
def brain_agents(name: str = '') -> str:
"""
Retourne les agents disponibles dans le brain.
Args:
name : Nom de l'agent (sans extension .md). Si vide, retourne la liste
complète. Exemple : "debug", "vps", "code-review".
Returns:
Liste des agents en tableau markdown (nom, status, context_tier, description)
ou contenu brut du fichier agents/{name}.md si name fourni.
Fallback filesystem si brain-engine indisponible.
"""
import json
import urllib.request
BRAIN_ROOT = Path(__file__).parent.parent
log.info('brain_agents name=%r', name)
if name:
agent_path = BRAIN_ROOT / 'agents' / f'{name}.md'
if not agent_path.exists():
return f'Agent introuvable : agents/{name}.md'
return agent_path.read_text(encoding='utf-8')
# Liste via brain-engine
try:
with urllib.request.urlopen('http://127.0.0.1:7700/agents', timeout=3) as resp:
data = json.loads(resp.read())
agents = data.get('agents', data) if isinstance(data, dict) else data
if not agents:
return 'Aucun agent trouvé.'
lines = ['## Agents disponibles\n', '| Nom | Status | Tier | Description |',
'|-----|--------|------|-------------|']
for ag in agents:
nom = ag.get('name', ag.get('id', '?'))
stat = ag.get('status', '')
tier = ag.get('context_tier', '')
desc = (ag.get('boot_summary') or ag.get('description') or '')[:80]
lines.append(f'| {nom} | {stat} | {tier} | {desc} |')
return '\n'.join(lines)
except Exception as exc:
log.warning('brain_agents HTTP failed, fallback filesystem: %s', exc)
# Fallback filesystem
agents_dir = BRAIN_ROOT / 'agents'
if not agents_dir.exists():
return 'Répertoire agents/ introuvable.'
files = sorted(agents_dir.glob('*.md'))
if not files:
return 'Aucun agent trouvé.'
lines = ['## Agents disponibles (filesystem)\n', '| Nom |', '|-----|']
for f in files:
lines.append(f'| {f.stem} |')
return '\n'.join(lines)
@mcp.tool()
def brain_decisions(last: int = 5) -> str:
"""
Retourne les dernières décisions architecturales (ADRs).
Lit les fichiers profil/decisions/*.md, triés par nom décroissant
(numérotation → plus récent en premier).
Args:
last : Nombre d'ADRs à retourner (défaut: 5).
Returns:
Bloc markdown avec numéro, titre, statut, date et résumé (150 chars)
de chaque ADR. "Aucune décision trouvée" si le répertoire est absent.
"""
BRAIN_ROOT = Path(__file__).parent.parent
log.info('brain_decisions last=%d', last)
decisions_dir = BRAIN_ROOT / 'profil' / 'decisions'
if not decisions_dir.exists():
return 'Aucune décision trouvée.'
files = sorted(decisions_dir.glob('*.md'), reverse=True)[:last]
if not files:
return 'Aucune décision trouvée.'
lines = ['## Décisions architecturales récentes\n']
for f in files:
body = f.read_text(encoding='utf-8')
# Extraire titre (première ligne # ...)
titre = next((l.lstrip('# ').strip() for l in body.splitlines() if l.startswith('#')), f.stem)
# Extraire statut et date depuis les premières lignes (format ADR standard)
statut = ''
date = ''
for line in body.splitlines():
ll = line.lower()
if ll.startswith('statut') or ll.startswith('status') or ll.startswith('- statut'):
statut = line.split(':', 1)[-1].strip()
if ll.startswith('date') or ll.startswith('- date'):
date = line.split(':', 1)[-1].strip()
# Résumé : premier paragraphe non-titre non-vide de moins de 150 chars
resume = ''
for line in body.splitlines():
if line.startswith('#') or not line.strip():
continue
resume = line.strip()[:150]
break
lines.append(f'### {f.stem}{titre}')
lines.append(f'**Statut** : {statut} | **Date** : {date}')
lines.append(f'{resume}')
lines.append('')
return '\n'.join(lines)
@mcp.tool()
def brain_focus() -> str:
"""
Retourne le focus actuel du brain.
Lit BRAIN_ROOT/focus.md et retourne le contenu brut.
Utile pour connaître la direction active, les projets en cours et les blockers.
Returns:
Contenu complet de focus.md ou "focus.md non trouvé".
"""
BRAIN_ROOT = Path(__file__).parent.parent
log.info('brain_focus')
focus_path = BRAIN_ROOT / 'focus.md'
if not focus_path.exists():
return 'focus.md non trouvé.'
return focus_path.read_text(encoding='utf-8')
@mcp.tool()
def brain_write(path: str, content: str) -> str:
"""
Écrit un fichier dans le brain via PUT /brain/{path}.
Réservé aux sessions owner. Permet de mettre à jour n'importe quel fichier
du brain depuis une session Claude avec MCP actif.
Args:
path : Chemin relatif dans le brain (ex: "focus.md", "todos/sprint.md").
content : Contenu complet du fichier à écrire.
Returns:
JSON {"ok": true, "path": path} en cas de succès,
message d'erreur sinon. 403 → "Requiert tier owner".
"""
import json
import urllib.request
log.info('brain_write path=%r len=%d', path, len(content))
url = f'http://127.0.0.1:7700/brain/{path}'
payload = json.dumps({'content': content}).encode('utf-8')
req = urllib.request.Request(
url, data=payload, method='PUT',
headers={'Content-Type': 'application/json'},
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
body = resp.read()
return json.dumps({'ok': True, 'path': path})
except urllib.error.HTTPError as exc:
if exc.code == 403:
return 'Requiert tier owner — écriture refusée.'
return f'Erreur {exc.code} : {exc.reason}'
except Exception as exc:
log.warning('brain_write failed: %s', exc)
return f'brain_write indisponible : {exc}'
# ── Entrypoint ─────────────────────────────────────────────────────────────────
if __name__ == '__main__':
import uvicorn
auth_status = 'token actif' if BRAIN_TOKEN_MCP else 'auth désactivée (dev)'
log.info('Brain MCP BE-4 — port %d%s — scopes: %s',
BRAIN_MCP_PORT, auth_status, MCP_SCOPES)
uvicorn.run(mcp_app, host='0.0.0.0', port=BRAIN_MCP_PORT,
forwarded_allow_ips='*', proxy_headers=True)

348
brain-engine/migrate.py Normal file
View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""
brain-engine/migrate.py — Migration BE-1 + BE-2b
Ingère les sources existantes du brain dans brain.db
Sources :
- claims/*.yml → table claims
- BRAIN-INDEX.md ## Signals → table signals (parsing markdown)
- handoffs/*.md → table handoffs (parsing frontmatter)
- claims → sessions → table sessions (dérivée depuis claims, BE-2b)
Usage :
python3 brain-engine/migrate.py [--dry-run] [--reset]
Anti-drift :
- Lecture seule sur les sources — jamais de modification des .md
- Idempotent — relancer ne duplique pas les données (UPSERT)
- En cas d'erreur parsing → warning + skip, pas de crash
"""
import sqlite3
import os
import re
import sys
import argparse
from datetime import datetime
BRAIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_PATH = os.path.join(BRAIN_ROOT, 'brain.db')
SCHEMA_PATH = os.path.join(BRAIN_ROOT, 'brain-engine', 'schema.sql')
def connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_schema(conn: sqlite3.Connection):
with open(SCHEMA_PATH) as f:
schema = f.read()
conn.executescript(schema)
conn.commit()
print(f"✅ Schema initialisé depuis {SCHEMA_PATH}")
def parse_yml_field(content: str, field: str, default=None) -> str:
"""Extrait un champ YAML simple (pas de parsing YAML complet — volontaire)."""
m = re.search(rf'^{re.escape(field)}:\s*(.+)', content, re.MULTILINE)
if m:
return m.group(1).strip().strip('"\'')
return default
def migrate_claims(conn: sqlite3.Connection, dry_run: bool = False) -> int:
"""Migre claims/*.yml → table claims."""
claims_dir = os.path.join(BRAIN_ROOT, 'claims')
if not os.path.isdir(claims_dir):
print(f"⚠️ claims/ introuvable : {claims_dir}")
return 0
count = 0
for filename in sorted(os.listdir(claims_dir)):
if not filename.startswith('sess-') or not filename.endswith('.yml'):
continue
filepath = os.path.join(claims_dir, filename)
try:
with open(filepath) as f:
content = f.read()
except Exception as e:
print(f" ⚠️ {filename} : erreur lecture — {e}")
continue
# Gère v1 (name:) et v2 (sess_id:)
sess_id = parse_yml_field(content, 'sess_id') or \
parse_yml_field(content, 'name', filename.replace('.yml', ''))
scope = parse_yml_field(content, 'scope', '')
status = parse_yml_field(content, 'status', 'closed')
opened_at = parse_yml_field(content, 'opened_at') or \
parse_yml_field(content, 'opened', '')
closed_at = parse_yml_field(content, 'closed_at') or \
parse_yml_field(content, 'closed')
sess_type = parse_yml_field(content, 'type', 'brain')
handoff_lvl = parse_yml_field(content, 'handoff_level')
story_angle = parse_yml_field(content, 'story_angle')
if not sess_id or sess_id == '':
print(f" ⚠️ {filename} : sess_id introuvable — skippé")
continue
if not dry_run:
conn.execute("""
INSERT INTO claims(sess_id, type, scope, status, opened_at, closed_at,
handoff_level, story_angle)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(sess_id) DO UPDATE SET
status=excluded.status,
closed_at=excluded.closed_at,
story_angle=excluded.story_angle
""", (sess_id, sess_type, scope, status, opened_at, closed_at,
handoff_lvl, story_angle))
else:
print(f" [dry] claim: {sess_id} | {status} | {scope}")
count += 1
if not dry_run:
conn.commit()
print(f"✅ Claims migrés : {count}")
return count
def migrate_signals(conn: sqlite3.Connection, dry_run: bool = False) -> int:
"""Migre ## Signals depuis BRAIN-INDEX.md → table signals."""
index_path = os.path.join(BRAIN_ROOT, 'BRAIN-INDEX.md')
if not os.path.exists(index_path):
print(f"⚠️ BRAIN-INDEX.md introuvable")
return 0
with open(index_path) as f:
content = f.read()
# Extraire la section ## Signals
m = re.search(r'## Signals.*?\n(.*?)(?=\n##|\Z)', content, re.DOTALL)
if not m:
print("⚠️ Section ## Signals non trouvée dans BRAIN-INDEX.md")
return 0
signals_section = m.group(1)
# Parser le tableau markdown
# Format : | sig_id | De | Pour | Type | Concerné | Payload | État |
row_pattern = re.compile(
r'^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|',
re.MULTILINE
)
count = 0
for m in row_pattern.finditer(signals_section):
sig_id, from_sess, to_sess, sig_type, projet, payload, state = [
v.strip() for v in m.groups()
]
# Ignorer les lignes d'en-tête
if sig_id.startswith('ID') or sig_id.startswith('-'):
continue
if not sig_id.startswith('sig-'):
continue
VALID_TYPES = {'READY_FOR_REVIEW', 'REVIEWED', 'BLOCKED_ON', 'HANDOFF', 'CHECKPOINT', 'INFO'}
if sig_type not in VALID_TYPES:
continue
state = state.lower().strip()
if state not in ('pending', 'delivered', 'archived'):
state = 'delivered'
if not dry_run:
conn.execute("""
INSERT INTO signals(sig_id, from_sess, to_sess, type, projet, payload, state, created_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(sig_id) DO UPDATE SET state=excluded.state
""", (sig_id, from_sess, to_sess, sig_type, projet, payload, state,
datetime.now().isoformat()))
else:
print(f" [dry] signal: {sig_id} | {sig_type} | {state}")
count += 1
if not dry_run:
conn.commit()
print(f"✅ Signals migrés : {count}")
return count
def migrate_handoffs(conn: sqlite3.Connection, dry_run: bool = False) -> int:
"""Migre handoffs/*.md → table handoffs."""
handoffs_dir = os.path.join(BRAIN_ROOT, 'handoffs')
if not os.path.isdir(handoffs_dir):
print(f"⚠️ handoffs/ introuvable : {handoffs_dir}")
return 0
count = 0
for filename in sorted(os.listdir(handoffs_dir)):
if not filename.endswith('.md') or filename.startswith('_'):
continue
filepath = os.path.join(handoffs_dir, filename)
try:
with open(filepath) as f:
content = f.read()
except Exception as e:
print(f" ⚠️ {filename} : erreur lecture — {e}")
continue
# Extraire le frontmatter
fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not fm_match:
continue
fm = fm_match.group(1)
htype = parse_yml_field(fm, 'type', 'HANDOFF')
projet = parse_yml_field(fm, 'projet') or parse_yml_field(fm, 'project')
status = parse_yml_field(fm, 'status', 'active')
from_s = parse_yml_field(fm, 'from') or parse_yml_field(fm, 'source')
created = parse_yml_field(fm, 'created') or parse_yml_field(fm, 'date',
datetime.now().strftime('%Y-%m-%d'))
if status not in ('active', 'consumed', 'archived'):
status = 'active'
if not dry_run:
conn.execute("""
INSERT INTO handoffs(filename, type, projet, status, from_sess, created_at)
VALUES (?,?,?,?,?,?)
ON CONFLICT(filename) DO UPDATE SET status=excluded.status
""", (filename, htype, projet, status, from_s, created))
else:
print(f" [dry] handoff: {filename} | {status}")
count += 1
if not dry_run:
conn.commit()
print(f"✅ Handoffs migrés : {count}")
return count
def migrate_sessions(conn: sqlite3.Connection, dry_run: bool = False) -> int:
"""
Peuple la table sessions depuis claims (BE-2b).
Stratégie : claims = sessions — chaque claim est une session brain.
Les champs metabolism (tokens_used, duration_min, etc.) restent NULL
jusqu'à ce que metabolism-scribe les alimente directement.
Mapping :
claims.sess_id → sessions.sess_id
claims.opened_at → sessions.date (partie date uniquement)
claims.type → sessions.type
claims.handoff_level → sessions.handoff_level
claims.health_score → sessions.health_score (si présent dans yml)
claims.cold_start_kpi_pass → sessions.cold_start_kpi_pass
"""
if dry_run:
rows = conn.execute("SELECT COUNT(*) as n FROM claims").fetchone()
print(f" [dry] sessions à créer depuis claims : {rows['n']}")
return rows['n']
# UPSERT : ne pas écraser les champs metabolism déjà renseignés
conn.execute("""
INSERT INTO sessions(sess_id, date, type, handoff_level, health_score, cold_start_kpi_pass)
SELECT
c.sess_id,
SUBSTR(c.opened_at, 1, 10) AS date,
c.type,
c.handoff_level,
c.health_score,
c.cold_start_kpi_pass
FROM claims c
WHERE TRUE
ON CONFLICT(sess_id) DO UPDATE SET
date = COALESCE(excluded.date, sessions.date),
type = COALESCE(excluded.type, sessions.type),
handoff_level = COALESCE(excluded.handoff_level, sessions.handoff_level),
health_score = COALESCE(excluded.health_score, sessions.health_score),
cold_start_kpi_pass = COALESCE(excluded.cold_start_kpi_pass, sessions.cold_start_kpi_pass)
""")
conn.commit()
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
kpi_row = conn.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN cold_start_kpi_pass = 1 THEN 1 ELSE 0 END) as passes
FROM sessions WHERE handoff_level = 'NO'
""").fetchone()
print(f"✅ Sessions migrées : {count}")
if kpi_row and kpi_row[0] > 0:
print(f" cold_start KPI (handoff=NO) : {kpi_row[1]}/{kpi_row[0]} passes")
return count
def main():
parser = argparse.ArgumentParser(description='Brain state engine — migration BE-1 + BE-2b')
parser.add_argument('--dry-run', action='store_true', help='Simulation sans écriture')
parser.add_argument('--reset', action='store_true', help='Supprimer brain.db avant migration')
parser.add_argument('--sessions-only', action='store_true', help='Rejouer uniquement migrate_sessions')
args = parser.parse_args()
if args.reset and os.path.exists(DB_PATH):
os.remove(DB_PATH)
print(f"♻️ brain.db supprimé — reconstruction depuis zéro")
print(f"Brain root : {BRAIN_ROOT}")
print(f"DB path : {DB_PATH}")
print(f"Mode : {'DRY RUN' if args.dry_run else 'WRITE'}")
print()
conn = connect(DB_PATH)
init_schema(conn)
if args.sessions_only:
print("\n── Sessions (replay) ───────────────────")
migrate_sessions(conn, dry_run=args.dry_run)
else:
print("\n── Claims ──────────────────────────────")
migrate_claims(conn, dry_run=args.dry_run)
print("\n── Signals ─────────────────────────────")
migrate_signals(conn, dry_run=args.dry_run)
print("\n── Handoffs ────────────────────────────")
migrate_handoffs(conn, dry_run=args.dry_run)
print("\n── Sessions ────────────────────────────")
migrate_sessions(conn, dry_run=args.dry_run)
if not args.dry_run:
# Vérification finale
print("\n── Vérification ────────────────────────")
for table in ('claims', 'signals', 'handoffs', 'agent_memory', 'sessions'):
row = conn.execute(f"SELECT COUNT(*) as n FROM {table}").fetchone()
print(f" {table:<15} : {row['n']} entrées")
print("\n── Vues ────────────────────────────────")
row = conn.execute("SELECT * FROM v_open_claims").fetchall()
print(f" v_open_claims : {len(row)} claim(s) open")
row = conn.execute("SELECT * FROM v_stale_claims").fetchall()
if row:
print(f" ⚠️ v_stale_claims : {len(row)} claim(s) stale !")
else:
print(f" v_stale_claims : ✅ aucun stale")
row = conn.execute("SELECT * FROM v_cold_start_kpi").fetchone()
if row and row['total_no_handoff'] > 0:
rate = row['pass_rate_pct'] or 0
print(f" v_cold_start_kpi: {row['passes']}/{row['total_no_handoff']} passes ({rate:.0f}%)")
conn.close()
print(f"\n✅ Migration terminée — brain.db prêt")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,30 @@
-- cold-start-kpi.sql — KPI North Star : NO HANDOFF productif < 2 min
-- Ref : brain-constitution.md §3
-- Usage : sqlite3 brain.db < brain-engine/queries/cold-start-kpi.sql
-- Vue globale
SELECT
total_no_handoff,
passes,
pass_rate_pct || '%' AS pass_rate,
CASE
WHEN pass_rate_pct >= 80 THEN '✅ Layer 0 stable'
WHEN pass_rate_pct >= 60 THEN '⚠️ Layer 0 à surveiller'
ELSE '🔴 Layer 0 insuffisant — enrichir brain-constitution.md'
END AS verdict
FROM v_cold_start_kpi;
-- Détail par session
SELECT
sess_id,
date,
CASE cold_start_kpi_pass
WHEN 1 THEN '✅ pass'
WHEN 0 THEN '❌ fail'
ELSE '— non mesuré'
END AS kpi,
notes
FROM sessions
WHERE handoff_level = 'NO'
ORDER BY date DESC
LIMIT 10;

View File

@@ -0,0 +1,16 @@
-- graduation-candidates.sql — Patterns L3a prêts pour graduation vers L3b (toolkit)
-- Usage : sqlite3 brain.db < brain-engine/queries/graduation-candidates.sql
SELECT
agent,
projet,
stack,
pattern_id,
validations,
seuil_graduation,
ROUND(CAST(validations AS REAL) / seuil_graduation * 100) || '%' AS progress,
last_validated
FROM agent_memory
WHERE graduated = 0
AND validations >= seuil_graduation
ORDER BY validations DESC;

View File

@@ -0,0 +1,24 @@
-- metabolism-dashboard.sql — Vue santé brain sur 7 jours
-- Usage : sqlite3 brain.db < brain-engine/queries/metabolism-dashboard.sql
-- Ratio use-brain / build-brain sur 7 jours
SELECT
COUNT(*) AS sessions_7d,
SUM(CASE WHEN type = 'build-brain' THEN 1 ELSE 0 END) AS build_brain,
SUM(CASE WHEN type = 'use-brain' THEN 1 ELSE 0 END) AS use_brain,
ROUND(
CAST(SUM(CASE WHEN type='use-brain' THEN 1 ELSE 0 END) AS REAL) /
NULLIF(SUM(CASE WHEN type='build-brain' THEN 1 ELSE 0 END), 0),
2) AS ratio_use_build,
ROUND(AVG(health_score), 2) AS avg_health_score,
CASE
WHEN ROUND(CAST(SUM(CASE WHEN type='use-brain' THEN 1 ELSE 0 END) AS REAL) /
NULLIF(SUM(CASE WHEN type='build-brain' THEN 1 ELSE 0 END), 0), 2) >= 1.0
THEN '✅ équilibré'
WHEN ROUND(CAST(SUM(CASE WHEN type='use-brain' THEN 1 ELSE 0 END) AS REAL) /
NULLIF(SUM(CASE WHEN type='build-brain' THEN 1 ELSE 0 END), 0), 2) >= 0.5
THEN '⚠️ à surveiller'
ELSE '🔴 boucle narcissique'
END AS verdict
FROM sessions
WHERE date >= date('now', '-7 days');

View File

@@ -0,0 +1,12 @@
-- stale-claims.sql — Claims ouverts depuis plus de 4h
-- Usage : sqlite3 brain.db < brain-engine/queries/stale-claims.sql
SELECT
sess_id,
scope,
opened_at,
ROUND((julianday('now') - julianday(opened_at)) * 24, 1) AS age_hours
FROM claims
WHERE status = 'open'
AND julianday('now') > julianday(opened_at, '+4 hours')
ORDER BY age_hours DESC;

190
brain-engine/rag.py Normal file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
brain-engine/rag.py — Couche RAG BE-3a
Enrichit le contexte Claude au boot avec des chunks additifs (non redondants avec helloWorld).
Usage :
python3 brain-engine/rag.py → boot queries (3 ciblées, skip helloWorld)
python3 brain-engine/rag.py "query custom" → query ad-hoc (compact)
python3 brain-engine/rag.py "query" --full → chunks complets
python3 brain-engine/rag.py --json → JSON brut (boot)
python3 brain-engine/rag.py "query" --json → JSON brut (ad-hoc)
python3 brain-engine/rag.py "query" --top 10 → top-10 résultats
Output : bloc markdown prêt à injection dans le contexte Claude.
Silencieux si aucun résultat ou Ollama indisponible (exit 0).
"""
import sys
import json
import argparse
from pathlib import Path
# Import search depuis le même répertoire
sys.path.insert(0, str(Path(__file__).parent))
from search import search as semantic_search
# ── Config ─────────────────────────────────────────────────────────────────────
# Fichiers déjà chargés par helloWorld — ignorés dans les résultats boot
# pour éviter de dupliquer le contexte déjà présent.
HELLOWORLD_SKIP = frozenset({
'focus.md',
'KERNEL.md',
'BRAIN-INDEX.md',
'agents/helloWorld.md',
'agents/secrets-guardian.md',
'agents/coach.md',
'profil/collaboration.md',
})
# Queries ciblées au boot — surface ce qu'helloWorld ne charge pas.
# Chaque tuple : (query, top_k)
RAG_BOOT_QUERIES = [
("décisions architecturales récentes", 3), # ADRs, choix archi
("todos prioritaires backlog actif", 3), # todo/*.md au-delà du README
("sprint en cours workspace actif", 2), # workspace/shadow-*/
]
# Seuil minimum au boot — évite le bruit des chunks peu pertinents
BOOT_MIN_SCORE = 0.30
# ── Core ───────────────────────────────────────────────────────────────────────
def run_boot_queries(allowed_scopes: list[str] | None = None) -> list[dict]:
"""
Exécute les 3 queries boot en séquence.
Déduplique par filepath, filtre les fichiers helloWorld.
Conserve la query source dans le champ '_query' pour le formatage.
"""
seen_filepaths: set[str] = set()
results: list[dict] = []
for query, top_k in RAG_BOOT_QUERIES:
hits = semantic_search(query, top_k=top_k, min_score=BOOT_MIN_SCORE,
allowed_scopes=allowed_scopes)
for hit in hits:
fp = hit['filepath']
if fp in HELLOWORLD_SKIP:
continue
if fp in seen_filepaths:
continue
seen_filepaths.add(fp)
results.append({**hit, '_query': query})
return results
def run_single_query(query: str, top_k: int = 5,
allowed_scopes: list[str] | None = None) -> list[dict]:
"""Query ad-hoc — pas de skip helloWorld, pas de déduplication inter-queries."""
hits = semantic_search(query, top_k=top_k, min_score=0.0,
allowed_scopes=allowed_scopes)
return [{**h, '_query': query} for h in hits]
# ── Formatage ──────────────────────────────────────────────────────────────────
def format_compact(results: list[dict], label: str = 'RAG boot') -> str:
"""
Format A (défaut) — filepath + extrait de 120 chars.
~100 tokens par chunk, lean pour injection boot.
"""
if not results:
return ''
lines = [f'## Brain context ({label})\n']
current_query: str | None = None
for r in results:
q = r.get('_query', '')
if q and q != current_query:
current_query = q
lines.append(f'\n### {q}\n')
fp = r['filepath']
score = r['score']
title = r.get('title') or ''
excerpt = r['chunk_text'].replace('\n', ' ')[:120].strip()
if title:
excerpt = f'[{title}] {excerpt}'
lines.append(f'- `{fp}` *(score: {score:.2f})* — {excerpt}\n')
return ''.join(lines)
def format_full(results: list[dict], label: str = 'RAG — full') -> str:
"""
Format B (--full) — chunks complets.
Pour queries ad-hoc profondes où l'extrait est insuffisant.
"""
if not results:
return ''
lines = [f'## Brain context ({label})\n']
for r in results:
fp = r['filepath']
score = r['score']
title = r.get('title') or ''
chunk = r['chunk_text']
header = f'### `{fp}`'
if title:
header += f'{title}'
header += f' *(score: {score:.2f})*'
lines.append(f'\n{header}\n\n{chunk}\n')
return ''.join(lines)
def format_json(results: list[dict]) -> str:
out = [{
'score': round(r['score'], 4),
'filepath': r['filepath'],
'title': r.get('title') or '',
'chunk_text': r['chunk_text'],
'query': r.get('_query', ''),
} for r in results]
return json.dumps(out, ensure_ascii=False, indent=2)
# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='brain-engine RAG — BE-3a')
parser.add_argument('query', nargs='?',
help='Query ad-hoc (sans arg = mode boot)')
parser.add_argument('--full', action='store_true',
help='Chunks complets (défaut: compact)')
parser.add_argument('--top', type=int, default=5,
help='Top-K pour query ad-hoc (défaut: 5)')
parser.add_argument('--json', action='store_true',
help='Output JSON brut')
args = parser.parse_args()
# Mode boot si aucune query fournie
if not args.query:
results = run_boot_queries()
label = 'RAG boot'
else:
results = run_single_query(args.query, top_k=args.top)
label = f'RAG — {args.query}'
# Silencieux si aucun résultat — ne pas polluer le contexte
if not results:
sys.exit(0)
if args.json:
print(format_json(results))
elif args.full:
print(format_full(results, label=label))
else:
print(format_compact(results, label=label))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,6 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
mcp[cli]>=1.0.0
PyYAML>=6.0
umap-learn>=0.5.6
numpy>=1.26.0

188
brain-engine/schema.sql Normal file
View File

@@ -0,0 +1,188 @@
-- brain-engine/schema.sql — Brain State Engine (BE-1)
-- Source de vérité : les .md restent souverains.
-- Ce schema est un INDEX QUERYABLE dérivé depuis les fichiers.
-- brain.db = lecture seule sur le brain — jamais d'écriture sur les .md.
--
-- Ref : ADR-012 (L3a), ADR-011 (autonomie), workspace/brain-engine/vision.md
-- Migration : brain-engine/migrate.py
PRAGMA journal_mode=WAL; -- Concurrent reads safe (multi-sessions)
PRAGMA foreign_keys=ON;
-- ── Claims BSI ───────────────────────────────────────────────────────────────
-- ADR-036 : source de vérité BSI — claims/*.yml migrent ici
CREATE TABLE IF NOT EXISTS claims (
sess_id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- brainstorm | work | deploy | debug | coach | brain
scope TEXT NOT NULL, -- ex: brain/memory-sql
status TEXT NOT NULL DEFAULT 'open', -- open | closed | stale
opened_at TEXT NOT NULL, -- ISO8601
closed_at TEXT, -- ISO8601 — null si encore open
handoff_level TEXT, -- NO | SEMI | SEMI+ | FULL
story_angle TEXT, -- angle narratif optionnel
health_score REAL, -- alimenté par metabolism-scribe au close
context_at_close INTEGER, -- % context utilisé au close
cold_start_kpi_pass INTEGER, -- 1=true 0=false NULL=non mesuré
-- BSI v3 fields (ADR-036)
ttl_hours INTEGER DEFAULT 4, -- TTL par défaut deep work
expires_at TEXT, -- ISO8601 — calculé au boot
instance TEXT, -- brain_name@machine
parent_sess TEXT, -- parent_satellite
satellite_type TEXT, -- code|brain-write|test|deploy|search|domain
satellite_level TEXT, -- leaf|domain
theme_branch TEXT, -- theme/<nom>
zone TEXT, -- kernel|project|personal (inféré)
mode TEXT, -- rendering|pilote|etc.
result_status TEXT, -- success|partial|fail
result_json TEXT, -- {files_modified, tests, children, signal_id}
CHECK(status IN ('open', 'closed', 'stale')),
CHECK(handoff_level IN ('NO', 'SEMI', 'SEMI+', 'FULL', NULL))
);
-- ── Locks BSI (ADR-036 — ex file-lock.sh) ──────────────────────────────────
CREATE TABLE IF NOT EXISTS locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filepath TEXT NOT NULL UNIQUE, -- chemin normalisé (ex: agents/foo.md)
holder TEXT NOT NULL, -- sess_id détenteur
claimed_at TEXT NOT NULL DEFAULT (datetime('now')), -- ISO8601
expires_at TEXT NOT NULL, -- ISO8601
ttl_min INTEGER NOT NULL DEFAULT 60
);
-- ── Circuit breaker BSI (ADR-036) ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS circuit_breaker (
sess_id TEXT PRIMARY KEY,
fail_count INTEGER NOT NULL DEFAULT 0,
last_fail_at TEXT, -- ISO8601
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Signaux inter-sessions ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS signals (
sig_id TEXT PRIMARY KEY, -- sig-YYYYMMDD-<seq>
from_sess TEXT, -- sess_id source
to_sess TEXT NOT NULL, -- sess_id cible ou brain_name@machine
type TEXT NOT NULL, -- READY_FOR_REVIEW | REVIEWED | BLOCKED_ON | HANDOFF | CHECKPOINT | INFO
projet TEXT,
payload TEXT, -- description ou chemin handoff file
state TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | archived
created_at TEXT NOT NULL, -- ISO8601
delivered_at TEXT,
CHECK(type IN ('READY_FOR_REVIEW','REVIEWED','BLOCKED_ON','HANDOFF','CHECKPOINT','INFO')),
CHECK(state IN ('pending','delivered','archived'))
);
-- ── Handoffs ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS handoffs (
filename TEXT PRIMARY KEY, -- handoffs/<nom>.md
type TEXT, -- CHECKPOINT | HANDOFF | FEEDBACK
projet TEXT,
status TEXT NOT NULL DEFAULT 'active', -- active | consumed | archived
from_sess TEXT,
consumed_by TEXT, -- sess_id qui a consommé ce handoff
created_at TEXT NOT NULL,
consumed_at TEXT,
CHECK(status IN ('active','consumed','archived'))
);
-- ── Mémoire agents L3a ────────────────────────────────────────────────────────
-- Alimenté par metabolism-scribe via kpi.yml dans agent-memory/<agent>/<projet>/
CREATE TABLE IF NOT EXISTS agent_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL, -- ex: tech-lead, debug, vps
projet TEXT NOT NULL, -- slug projet
stack TEXT NOT NULL, -- ex: node-express-jwt
pattern_id TEXT NOT NULL, -- slug du pattern
validations INTEGER NOT NULL DEFAULT 0, -- sessions où le pattern a été validé
kpi_score REAL NOT NULL DEFAULT 0.0, -- 0.0 → 1.0
graduated INTEGER NOT NULL DEFAULT 0, -- 0=false 1=true (→ L3b toolkit)
seuil_graduation INTEGER NOT NULL DEFAULT 3,
last_validated TEXT, -- ISO8601
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(agent, projet, stack, pattern_id)
);
-- ── Sessions metabolism ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS sessions (
sess_id TEXT PRIMARY KEY,
date TEXT NOT NULL,
type TEXT, -- build-brain | use-brain | auto
mode TEXT,
handoff_level TEXT,
tokens_used INTEGER,
context_peak_pct INTEGER,
context_at_close INTEGER,
duration_min INTEGER,
commits INTEGER,
todos_closed INTEGER,
saturation_flag INTEGER, -- 0/1
health_score REAL,
cold_start_kpi_pass INTEGER, -- 0/1/NULL
notes TEXT
);
-- ── Agents chargés par session ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS agent_loads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sess_id TEXT NOT NULL REFERENCES claims(sess_id),
agent TEXT NOT NULL,
tokens_estimated INTEGER,
loaded_at TEXT NOT NULL DEFAULT (datetime('now')),
reason TEXT -- why it was loaded
);
-- ── Vues utilitaires ─────────────────────────────────────────────────────────
CREATE VIEW IF NOT EXISTS v_open_claims AS
SELECT sess_id, scope, opened_at,
ROUND((julianday('now') - julianday(opened_at)) * 24, 1) AS age_hours
FROM claims
WHERE status = 'open'
ORDER BY opened_at DESC;
CREATE VIEW IF NOT EXISTS v_stale_claims AS
SELECT sess_id, scope, opened_at,
ROUND((julianday('now') - julianday(opened_at)) * 24, 1) AS age_hours
FROM claims
WHERE status = 'open'
AND julianday('now') > julianday(opened_at, '+4 hours')
ORDER BY age_hours DESC;
CREATE VIEW IF NOT EXISTS v_active_locks AS
SELECT filepath, holder, claimed_at, expires_at,
CASE WHEN julianday('now') < julianday(expires_at) THEN 'active' ELSE 'expired' END AS lock_status
FROM locks
ORDER BY claimed_at DESC;
CREATE VIEW IF NOT EXISTS v_graduation_candidates AS
SELECT agent, projet, stack, pattern_id, validations, kpi_score,
ROUND(CAST(validations AS REAL) / seuil_graduation, 2) AS progress
FROM agent_memory
WHERE graduated = 0
AND validations >= seuil_graduation
ORDER BY validations DESC;
CREATE VIEW IF NOT EXISTS v_cold_start_kpi AS
SELECT
COUNT(*) AS total_no_handoff,
SUM(CASE WHEN cold_start_kpi_pass = 1 THEN 1 ELSE 0 END) AS passes,
ROUND(
100.0 * SUM(CASE WHEN cold_start_kpi_pass = 1 THEN 1 ELSE 0 END)
/ NULLIF(SUM(CASE WHEN cold_start_kpi_pass IS NOT NULL THEN 1 ELSE 0 END), 0),
1) AS pass_rate_pct
FROM sessions
WHERE handoff_level = 'NO';
CREATE VIEW IF NOT EXISTS v_metabolism_7d AS
SELECT
date,
type,
AVG(health_score) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS health_7d_avg,
SUM(CASE WHEN type='build-brain' THEN 1 ELSE 0 END)
OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS build_7d,
SUM(CASE WHEN type='use-brain' THEN 1 ELSE 0 END)
OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS use_7d
FROM sessions
ORDER BY date DESC;

227
brain-engine/search.py Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
brain-engine/search.py — Recherche sémantique BE-2d
Embed une query → cosine similarity sur brain.db → top-K chunks
Usage :
python3 brain-engine/search.py "décisions archi SuperOAuth"
python3 brain-engine/search.py "cold start" --top 10
python3 brain-engine/search.py "agents helloWorld" --mode file
python3 brain-engine/search.py "sessions metabolism" --mode json
Modes :
human (défaut) → tableau lisible : score | filepath | extrait
file → filepaths dédupliqués, triés par score (pour Claude : charger ces fichiers)
json → JSON brut : [{score, filepath, title, chunk_text}]
Headless : zéro dépendance display/Wayland.
OLLAMA_URL : variable d'env (défaut localhost:11434).
"""
import os
import sys
import json
import struct
import argparse
import sqlite3
import urllib.request
import urllib.error
from pathlib import Path
BRAIN_ROOT = Path(__file__).parent.parent
DB_PATH = BRAIN_ROOT / 'brain.db'
OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://localhost:11434')
EMBED_MODEL = os.getenv('EMBED_MODEL', 'nomic-embed-text')
# Guardrail — cohérent avec embed.py
_BLOCKED_MODELS = ['mistral', 'qwen', 'llama', 'gemma', 'phi', 'deepseek']
if any(b in EMBED_MODEL.lower() for b in _BLOCKED_MODELS):
sys.exit(f"❌ EMBED_MODEL='{EMBED_MODEL}' interdit — utiliser nomic-embed-text ou mxbai-embed-large")
# ── Maths ─────────────────────────────────────────────────────────────────────
def cosine_sim(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
def blob_to_vector(blob: bytes) -> list[float]:
n = len(blob) // 4
return list(struct.unpack(f'{n}f', blob))
# ── Ollama ─────────────────────────────────────────────────────────────────────
def embed_query(text: str) -> list[float] | None:
url = f"{OLLAMA_URL}/api/embeddings"
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
req = urllib.request.Request(url, data=payload,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
return data.get('embedding')
except (urllib.error.URLError, TimeoutError) as e:
print(f"❌ Ollama indisponible ({OLLAMA_URL}) : {e}", file=sys.stderr)
return None
# ── SQLite ─────────────────────────────────────────────────────────────────────
def load_vectors(conn: sqlite3.Connection,
allowed_scopes: list[str] | None = None,
include_historical: bool = False) -> list[dict]:
"""Charge les chunks indexés depuis brain.db, filtrés par scope si fourni.
Shadow indexing (ADR-037) : scope='historical' exclu par défaut."""
historical_filter = "" if include_historical else "AND scope != 'historical'"
if allowed_scopes:
placeholders = ','.join('?' * len(allowed_scopes))
rows = conn.execute(f"""
SELECT chunk_id, filepath, title, chunk_text, vector
FROM embeddings
WHERE indexed = 1 AND vector IS NOT NULL
AND scope IN ({placeholders})
{historical_filter}
""", allowed_scopes).fetchall()
else:
rows = conn.execute(f"""
SELECT chunk_id, filepath, title, chunk_text, vector
FROM embeddings
WHERE indexed = 1 AND vector IS NOT NULL
{historical_filter}
""").fetchall()
result = []
for row in rows:
result.append({
'chunk_id': row['chunk_id'],
'filepath': row['filepath'],
'title': row['title'] or '',
'chunk_text': row['chunk_text'],
'vector': blob_to_vector(row['vector']),
})
return result
# ── Search ─────────────────────────────────────────────────────────────────────
def search(query: str, top_k: int = 5, min_score: float = 0.0,
allowed_scopes: list[str] | None = None) -> list[dict]:
"""Retourne les top-K chunks les plus proches de la query."""
# 1. Embed la query
q_vec = embed_query(query)
if q_vec is None:
return []
# 2. Charger les vecteurs (filtrés par scope si fourni)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
chunks = load_vectors(conn, allowed_scopes=allowed_scopes)
conn.close()
if not chunks:
print("⚠️ Index vide — lancer embed.py d'abord", file=sys.stderr)
return []
# 3. Cosine similarity
scored = []
for chunk in chunks:
score = cosine_sim(q_vec, chunk['vector'])
if score >= min_score:
scored.append({**chunk, 'score': score})
# 4. Trier, dédupliquer par chunk_id (déjà unique), retourner top-K
scored.sort(key=lambda x: x['score'], reverse=True)
top_results = scored[:top_k]
# 5. Tracking V1 (ADR-037) — hit_count + last_queried_at sur les chunks retournés
if top_results:
try:
track_conn = sqlite3.connect(DB_PATH)
chunk_ids = [r['chunk_id'] for r in top_results if r.get('chunk_id')]
if chunk_ids:
placeholders = ','.join('?' * len(chunk_ids))
track_conn.execute(f"""
UPDATE embeddings
SET hit_count = COALESCE(hit_count, 0) + 1,
last_queried_at = datetime('now')
WHERE chunk_id IN ({placeholders})
""", chunk_ids)
track_conn.commit()
track_conn.close()
except Exception:
pass # tracking is best-effort — never breaks search
return top_results
# ── Output ─────────────────────────────────────────────────────────────────────
def print_human(results: list[dict], query: str):
if not results:
print(f"Aucun résultat pour : {query!r}")
return
print(f"\nRecherche : {query!r} ({len(results)} résultat(s))\n")
print(f"{'Score':>6} {'Fichier':<50} Extrait")
print("" * 100)
for r in results:
score = f"{r['score']:.3f}"
fp = r['filepath']
if len(fp) > 50:
fp = '' + fp[-49:]
title = r['title']
excerpt = r['chunk_text'].replace('\n', ' ')[:80]
if title:
excerpt = f"[{title}] {excerpt}"
print(f"{score:>6} {fp:<50} {excerpt}")
print()
def print_files(results: list[dict]):
"""Filepaths dédupliqués, ordre par meilleur score."""
seen = []
for r in results:
if r['filepath'] not in seen:
seen.append(r['filepath'])
for fp in seen:
print(fp)
def print_json(results: list[dict]):
out = [{
'score': round(r['score'], 4),
'filepath': r['filepath'],
'title': r['title'],
'chunk_text': r['chunk_text'],
} for r in results]
print(json.dumps(out, ensure_ascii=False, indent=2))
# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='brain-engine search — BE-2d')
parser.add_argument('query', help='Requête en langage naturel')
parser.add_argument('--top', type=int, default=5, help='Nombre de résultats (défaut: 5)')
parser.add_argument('--mode', choices=['human', 'file', 'json'], default='human',
help='Format de sortie (défaut: human)')
parser.add_argument('--min-score', type=float, default=0.0,
help='Score minimum cosine (0.01.0, défaut: 0.0)')
args = parser.parse_args()
results = search(args.query, top_k=args.top, min_score=args.min_score)
if args.mode == 'file':
print_files(results)
elif args.mode == 'json':
print_json(results)
else:
print_human(results, args.query)
if __name__ == '__main__':
main()

1531
brain-engine/server.py Normal file

File diff suppressed because it is too large Load Diff

74
brain-engine/start.sh Executable file
View File

@@ -0,0 +1,74 @@
#!/bin/bash
# brain-engine/start.sh — Démarrage standalone
# Usage : bash brain-engine/start.sh
# Prérequis : Python 3.10+, Ollama (pour l'embedding — optionnel au premier boot)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BRAIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "=== brain-engine — standalone boot ==="
echo "Brain root : $BRAIN_ROOT"
# 1. Vérifier Python
if ! command -v python3 &>/dev/null; then
echo "❌ Python 3 requis. Installe-le : sudo apt install python3 python3-pip python3-venv"
exit 1
fi
# 2. Installer les dépendances (venv recommandé)
if [ ! -d "$SCRIPT_DIR/.venv" ]; then
echo "→ Création environnement virtuel..."
python3 -m venv "$SCRIPT_DIR/.venv"
fi
source "$SCRIPT_DIR/.venv/bin/activate"
pip install -q -r "$SCRIPT_DIR/requirements.txt"
# 3. Initialiser brain.db si absent
if [ ! -f "$BRAIN_ROOT/brain.db" ]; then
echo "→ Initialisation brain.db..."
python3 "$SCRIPT_DIR/migrate.py" --reset 2>/dev/null || python3 "$SCRIPT_DIR/migrate.py"
echo "✅ brain.db créé"
else
echo "✅ brain.db existant"
fi
# 4. Embedding (optionnel — requiert Ollama)
if command -v ollama &>/dev/null; then
INDEXED=$(python3 -c "
import sqlite3, os
db = os.path.join('$BRAIN_ROOT', 'brain.db')
if os.path.exists(db):
c = sqlite3.connect(db)
try: print(c.execute('SELECT COUNT(*) FROM embeddings WHERE indexed=1').fetchone()[0])
except: print(0)
c.close()
else: print(0)
" 2>/dev/null || echo "0")
if [ "$INDEXED" = "0" ]; then
echo "→ Premier embedding du corpus (Ollama détecté)..."
python3 "$SCRIPT_DIR/embed.py"
echo "✅ Corpus indexé"
else
echo "$INDEXED chunks déjà indexés"
fi
else
echo "⚠️ Ollama non détecté — la recherche sémantique ne fonctionnera pas."
echo " Installe Ollama : curl -fsSL https://ollama.com/install.sh | sh"
echo " Puis : ollama pull nomic-embed-text && bash brain-engine/start.sh"
echo " Le serveur démarre quand même (BSI, docs, endpoints basiques)."
fi
# 5. Lancer le serveur
PORT="${BRAIN_PORT:-7700}"
echo ""
echo "=== Lancement brain-engine sur port $PORT ==="
echo " Health : http://localhost:$PORT/health"
echo " Search : http://localhost:$PORT/search?q=comment+ca+marche"
echo " Agents : http://localhost:$PORT/agents"
echo ""
cd "$BRAIN_ROOT"
python3 "$SCRIPT_DIR/server.py"

File diff suppressed because it is too large Load Diff

33
brain-ui/build.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# brain-ui/build.sh — Build le dashboard pour servir via brain-engine
# Usage : bash brain-ui/build.sh
# Prérequis : Node.js 18+, npm
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== brain-ui — build ==="
# 1. Vérifier Node
if ! command -v node &>/dev/null; then
echo "❌ Node.js requis (18+). Installe-le : https://nodejs.org/"
exit 1
fi
# 2. Install deps
cd "$SCRIPT_DIR"
if [ ! -d "node_modules" ]; then
echo "→ Installation des dépendances..."
npm install
fi
# 3. Build (skip type check — erreurs TS pré-existantes non bloquantes)
echo "→ Build en cours..."
npx vite build
echo ""
echo "✅ brain-ui build dans dist/"
echo " Servi automatiquement par brain-engine sur /ui/"
echo " Lance : bash brain-engine/start.sh"
echo " Puis ouvre : http://localhost:7700/ui/"

12
brain-ui/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brain UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
brain-ui/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "brain-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@reactflow/core": "^11.11.4",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"reactflow": "^11.11.4",
"three": "^0.163.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.163.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1 @@
../../../docs/README.md

View File

@@ -0,0 +1 @@
../../../docs/agents-brain.md

View File

@@ -0,0 +1 @@
../../../docs/agents-code.md

View File

@@ -0,0 +1 @@
../../../docs/agents-infra.md

View File

@@ -0,0 +1 @@
../../../docs/agents.md

View File

@@ -0,0 +1 @@
../../../docs/architecture.md

View File

@@ -0,0 +1 @@
../../../docs/getting-started.md

View File

@@ -0,0 +1 @@
../../../docs/sessions.md

View File

@@ -0,0 +1 @@
../../../docs/vue-featured.md

View File

@@ -0,0 +1 @@
../../../docs/vue-free.md

View File

@@ -0,0 +1 @@
../../../docs/vue-full.md

View File

@@ -0,0 +1 @@
../../../docs/vue-pro.md

View File

@@ -0,0 +1 @@
../../../docs/vue-tiers.md

View File

@@ -0,0 +1 @@
../../../docs/workflows.md

301
brain-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,301 @@
import { useState, useEffect, Suspense, lazy } from 'react'
import WorkflowBoard from './components/WorkflowBoard'
import SecretsZone, { MOCK_SECTIONS } from './components/SecretsZone'
import WorkflowBuilder from './components/WorkflowBuilder'
import GatesDrawer from './components/GatesDrawer'
import GateDrawer from './components/GateDrawer'
import LogDrawer from './components/LogDrawer'
import CommandPalette from './components/CommandPalette'
import TierGate from './components/TierGate'
import InfraRegistry from './components/InfraRegistry'
import { ToastProvider, useToast } from './components/ToastProvider'
import { useWorkflows } from './hooks/useWorkflows'
import { useWebSocket } from './hooks/useWebSocket'
import { useBrainStore } from './store/brain.store'
import { useTier } from './hooks/useTier'
const CosmosView = lazy(() => import('./components/cosmos/CosmosView'))
const WorkspaceView = lazy(() => import('./components/workspace/WorkspaceView'))
const DocsView = lazy(() => import('./components/DocsView'))
type ActiveView = 'workflows' | 'builder' | 'secrets' | 'infra' | 'cosmos' | 'workspace' | 'docs'
interface NavItem {
id: ActiveView
icon: string
label: string
separator?: boolean
}
interface PendingGate {
workflowId: string
stepId: string
stepLabel: string
}
const NAV_ITEMS: NavItem[] = [
{ id: 'workflows', icon: '🔀', label: 'Workflows' },
{ id: 'builder', icon: '⚡', label: 'Nouveau' },
{ id: 'secrets', icon: '🔑', label: 'Secrets' },
{ id: 'infra', icon: '🖥️', label: 'Infra' },
{ id: 'cosmos', icon: '🌌', label: 'Cosmos', separator: true },
{ id: 'docs', icon: '📖', label: 'Docs' },
]
function AppInner() {
const { addToast } = useToast()
const [activeView, setActiveView] = useState<ActiveView>('workflows')
const [pendingGate, setPendingGate] = useState<PendingGate | null>(null)
const [gateDrawer, setGateDrawer] = useState<{ open: boolean; workflowId: string | null; stepId: string | null }>({
open: false,
workflowId: null,
stepId: null,
})
const [logsProject, setLogsProject] = useState<string | null>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
const { workflows, wsStatus } = useWorkflows()
useWebSocket(addToast)
const storeWorkflows = useBrainStore((s) => s.workflows)
const { hasFeature, tierInfo } = useTier()
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setPaletteOpen(true)
}
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
e.preventDefault()
setLogsProject((prev) => (prev ? null : (storeWorkflows[0]?.id ?? null)))
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [storeWorkflows])
const handleGateApprove = (workflowId: string, stepId: string) => {
const wf = storeWorkflows.find((w) => w.id === workflowId)
const step = wf?.steps.find((s) => s.id === stepId)
const label = step?.label ?? stepId
setPendingGate({ workflowId, stepId, stepLabel: label })
setGateDrawer({ open: true, workflowId, stepId })
}
const handleSecretSave = (section: string, key: string, value: string) => {
console.log(`secret:save — ${section}.${key} (${value.length} chars)`)
// TODO: appel API brain
}
return (
<div className="flex h-screen w-screen overflow-hidden" style={{ background: '#0d0d0d', color: '#e5e7eb' }}>
{/* Sidebar */}
<aside
className="flex flex-col flex-shrink-0 border-r"
style={{ width: 220, background: '#1a1a1a', borderColor: '#2a2a2a' }}
>
{/* Header / Logo */}
<div className="flex items-center gap-2 px-4 py-4 border-b" style={{ borderColor: '#2a2a2a' }}>
<span className="font-bold text-white tracking-tight text-lg">brain ui</span>
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: '#2a2a2a', color: '#9ca3af' }}
>
v0.2.0
</span>
</div>
{/* Kernel status */}
<div className="flex items-center gap-2 px-4 py-2 border-b" style={{ borderColor: '#2a2a2a' }}>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
background:
wsStatus === 'connected' ? '#22c55e' :
wsStatus === 'error' ? '#ef4444' : '#6b7280',
}}
/>
<span className="text-xs" style={{ color: '#6b7280' }}>
{wsStatus === 'connected' ? 'kernel connecté' :
wsStatus === 'error' ? 'kernel erreur' : 'kernel déconnecté'}
</span>
</div>
{/* Navigation */}
<nav className="flex flex-col gap-0.5 mt-3 px-2">
{NAV_ITEMS.map((item) => {
const isActive = activeView === item.id
return (
<div key={item.id}>
{item.separator && (
<div className="mx-3 my-1" style={{ borderTop: '1px solid #2a2a2a' }} />
)}
<button
onClick={() => setActiveView(item.id)}
className="flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-left transition-colors w-full"
style={
isActive
? {
background: 'rgba(99,102,241,0.2)',
color: '#6366f1',
borderLeft: '2px solid #6366f1',
paddingLeft: 10,
}
: {
color: '#9ca3af',
borderLeft: '2px solid transparent',
paddingLeft: 10,
}
}
>
<span className="text-base leading-none">{item.icon}</span>
<span>{item.label}</span>
</button>
</div>
)
})}
</nav>
{/* Bouton Logs */}
<div className="px-2 mt-2">
<button
onClick={() => setLogsProject((prev) => (prev ? null : (storeWorkflows[0]?.id ?? 'ambient')))}
className="flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-left w-full transition-colors"
style={
logsProject
? {
background: 'rgba(99,102,241,0.2)',
color: '#6366f1',
borderLeft: '2px solid #6366f1',
paddingLeft: 10,
}
: {
color: '#9ca3af',
borderLeft: '2px solid transparent',
paddingLeft: 10,
}
}
>
<span className="text-base leading-none">📋</span>
<span>Logs</span>
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#4b5563', fontFamily: 'monospace' }}>L</span>
</button>
</div>
{/* Tier badge — en bas de sidebar avant ⌘K */}
<div style={{ padding: '4px 16px', color: '#374151', fontSize: 10, fontFamily: 'monospace' }}>
{tierInfo.tier}
</div>
{/* Cmd+K hint */}
<div className="mt-auto px-4 py-3 border-t" style={{ borderColor: '#2a2a2a' }}>
<button
onClick={() => setPaletteOpen(true)}
className="flex items-center gap-2 w-full text-xs font-mono"
style={{ color: '#4b5563', background: 'transparent' }}
>
<span>K</span>
<span>Commandes</span>
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-hidden flex flex-col">
{activeView === 'workflows' && (
<WorkflowBoard
workflows={workflows}
onGateApprove={handleGateApprove}
onWorkflowClick={(wfId) => setLogsProject(wfId)}
/>
)}
{activeView === 'builder' && (
<WorkflowBuilder />
)}
{activeView === 'secrets' && (
<TierGate feature="secrets" hasFeature={hasFeature}>
<SecretsZone sections={MOCK_SECTIONS} onSecretSave={handleSecretSave} />
</TierGate>
)}
{activeView === 'infra' && (
<TierGate feature="infra" hasFeature={hasFeature}>
<InfraRegistry />
</TierGate>
)}
{activeView === 'cosmos' && (
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
<Suspense fallback={
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5563' }}>
<span className="text-sm font-mono">Chargement Cosmos...</span>
</div>
}>
<CosmosView />
</Suspense>
</div>
)}
{activeView === 'docs' && (
<Suspense fallback={
<div className="flex items-center justify-center h-full" style={{ color: '#4b5563' }}>
<span className="text-sm font-mono">Chargement Docs...</span>
</div>
}>
<DocsView />
</Suspense>
)}
{activeView === 'workspace' && (
<Suspense fallback={
<div className="flex items-center justify-center h-full" style={{ color: '#4b5563' }}>
<span className="text-sm font-mono">Chargement Workspace...</span>
</div>
}>
<WorkspaceView />
</Suspense>
)}
</main>
{/* GatesDrawer — affiché si gate en attente */}
{pendingGate && (
<GatesDrawer
workflowId={pendingGate.workflowId}
stepId={pendingGate.stepId}
stepLabel={pendingGate.stepLabel}
onApprove={async () => setPendingGate(null)}
onReject={async () => setPendingGate(null)}
onClose={() => setPendingGate(null)}
/>
)}
{/* LogDrawer — slide-in depuis la droite */}
<LogDrawer
open={logsProject !== null}
project={logsProject}
onClose={() => setLogsProject(null)}
/>
{/* GateDrawer — approbation workflow SuperOAuth */}
<GateDrawer
open={gateDrawer.open}
workflowId={gateDrawer.workflowId}
stepId={gateDrawer.stepId}
onClose={() => setGateDrawer((prev) => ({ ...prev, open: false }))}
/>
{/* CommandPalette — Cmd+K */}
{paletteOpen && (
<CommandPalette
onClose={() => setPaletteOpen(false)}
onNavigate={(view) => { setActiveView(view as ActiveView); setPaletteOpen(false) }}
/>
)}
</div>
)
}
export default function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
)
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect, useRef, useCallback } from 'react'
interface PaletteCommand {
id: string
label: string
description: string
keywords: string[]
action: () => void
}
interface CommandPaletteProps {
onClose: () => void
onNavigate: (view: string) => void
}
export default function CommandPalette({ onClose, onNavigate }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIdx, setSelectedIdx] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const commands: PaletteCommand[] = [
{
id: 'workspace:open',
label: 'Espace Workflow 3D',
description: "Piloter les workflows dans l'espace",
keywords: ['workspace', '3d', 'workflow', 'constellation', 'space'],
action: () => { onNavigate('workspace'); onClose() },
},
{
id: 'cosmos:open',
label: 'Ouvrir Cosmos',
description: 'Visualisation 3D du brain',
keywords: ['cosmos', '3d', 'brain', 'visualisation', 'points', 'umap'],
action: () => { onNavigate('cosmos'); onClose() },
},
{
id: 'workflows:view',
label: 'Workflows',
description: 'Voir les workflows actifs',
keywords: ['workflows', 'pipeline', 'tasks'],
action: () => { onNavigate('workflows'); onClose() },
},
{
id: 'builder:open',
label: 'Nouveau workflow',
description: 'Ouvrir le WorkflowBuilder',
keywords: ['builder', 'nouveau', 'create', 'workflow', 'new'],
action: () => { onNavigate('builder'); onClose() },
},
{
id: 'secrets:view',
label: 'Secrets',
description: 'Gérer les secrets et tokens',
keywords: ['secrets', 'tokens', 'keys', 'env'],
action: () => { onNavigate('secrets'); onClose() },
},
{
id: 'infra:view',
label: 'Infra',
description: 'Registre infrastructure',
keywords: ['infra', 'infrastucture', 'servers', 'vps'],
action: () => { onNavigate('infra'); onClose() },
},
]
const filtered = query.trim()
? commands.filter((cmd) => {
const q = query.toLowerCase()
return (
cmd.label.toLowerCase().includes(q) ||
cmd.description.toLowerCase().includes(q) ||
cmd.keywords.some((kw) => kw.includes(q))
)
})
: commands
useEffect(() => {
setSelectedIdx(0)
}, [query])
useEffect(() => {
inputRef.current?.focus()
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIdx((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
if (filtered[selectedIdx]) {
filtered[selectedIdx].action()
}
}
}, [filtered, selectedIdx, onClose])
return (
<div
className="fixed inset-0 flex items-start justify-center"
style={{ background: 'rgba(0,0,0,0.6)', zIndex: 100, paddingTop: 80 }}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="w-full rounded-lg overflow-hidden"
style={{
maxWidth: 512,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
onKeyDown={handleKeyDown}
>
{/* Input */}
<div style={{ borderBottom: '1px solid #2a2a2a' }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Taper une commande..."
className="w-full px-4 py-3 text-sm font-mono outline-none"
style={{
background: 'transparent',
color: '#e5e7eb',
}}
/>
</div>
{/* Commands list */}
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div
className="px-4 py-3 text-xs font-mono"
style={{ color: '#4b5563' }}
>
Aucune commande trouvée
</div>
)}
{filtered.map((cmd, idx) => (
<button
key={cmd.id}
onClick={cmd.action}
onMouseEnter={() => setSelectedIdx(idx)}
className="w-full flex items-start gap-3 px-4 py-3 text-left"
style={{
background: idx === selectedIdx ? 'rgba(99,102,241,0.1)' : 'transparent',
borderLeft: `2px solid ${idx === selectedIdx ? '#6366f1' : 'transparent'}`,
}}
>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium"
style={{ color: '#e5e7eb' }}
>
{cmd.label}
</div>
<div
className="text-xs mt-0.5 font-mono"
style={{ color: '#6b7280' }}
>
{cmd.description}
</div>
</div>
<div
className="text-xs font-mono flex-shrink-0 mt-0.5"
style={{ color: '#4b5563' }}
>
{cmd.id}
</div>
</button>
))}
</div>
{/* Footer */}
<div
className="flex items-center gap-4 px-4 py-2"
style={{ borderTop: '1px solid #2a2a2a', color: '#4b5563', fontSize: 10, fontFamily: 'monospace' }}
>
<span> naviguer</span>
<span> exécuter</span>
<span>Esc fermer</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect, ReactNode } from 'react'
import ReactMarkdown, { Components } from 'react-markdown'
interface DocFile {
name: string
label: string
path: string
group?: string
}
const DOCS: DocFile[] = [
{ name: 'getting-started', label: 'Demarrer', path: import.meta.env.BASE_URL + 'docs/getting-started.md', group: 'Guides' },
{ name: 'architecture', label: 'Architecture', path: import.meta.env.BASE_URL + 'docs/architecture.md', group: 'Guides' },
{ name: 'sessions', label: 'Sessions', path: import.meta.env.BASE_URL + 'docs/sessions.md', group: 'Guides' },
{ name: 'workflows', label: 'Workflows', path: import.meta.env.BASE_URL + 'docs/workflows.md', group: 'Guides' },
{ name: 'agents', label: 'Vue d\'ensemble', path: import.meta.env.BASE_URL + 'docs/agents.md', group: 'Agents' },
{ name: 'agents-code', label: 'Code & Qualite', path: import.meta.env.BASE_URL + 'docs/agents-code.md', group: 'Agents' },
{ name: 'agents-infra', label: 'Infra & Deploy', path: import.meta.env.BASE_URL + 'docs/agents-infra.md', group: 'Agents' },
{ name: 'agents-brain', label: 'Brain & Systeme', path: import.meta.env.BASE_URL + 'docs/agents-brain.md', group: 'Agents' },
{ name: 'vue-tiers', label: 'Comparatif', path: import.meta.env.BASE_URL + 'docs/vue-tiers.md', group: 'Vues' },
{ name: 'vue-free', label: '🟢 free', path: import.meta.env.BASE_URL + 'docs/vue-free.md', group: 'Vues' },
{ name: 'vue-featured', label: '🔵 featured', path: import.meta.env.BASE_URL + 'docs/vue-featured.md', group: 'Vues' },
{ name: 'vue-pro', label: '🟠 pro', path: import.meta.env.BASE_URL + 'docs/vue-pro.md', group: 'Vues' },
{ name: 'vue-full', label: '🟣 full', path: import.meta.env.BASE_URL + 'docs/vue-full.md', group: 'Vues' },
]
// Detect tier markers in blockquote content and apply CSS class
const TIER_MARKERS: Record<string, string> = {
'\u{1F7E2}': 'tier-free', // 🟢
'\u{1F535}': 'tier-featured', // 🔵
'\u{1F7E0}': 'tier-pro', // 🟠
'\u{1F7E3}': 'tier-full', // 🟣
}
function extractText(children: ReactNode): string {
if (typeof children === 'string') return children
if (Array.isArray(children)) return children.map(extractText).join('')
if (children && typeof children === 'object' && 'props' in children) {
return extractText((children as { props: { children?: ReactNode } }).props.children)
}
return ''
}
const mdComponents: Components = {
blockquote({ children }) {
const text = extractText(children)
let tierClass = ''
for (const [marker, cls] of Object.entries(TIER_MARKERS)) {
if (text.includes(marker)) {
tierClass = cls
break
}
}
return <blockquote className={tierClass || undefined}>{children}</blockquote>
},
}
export default function DocsView() {
const [activeDoc, setActiveDoc] = useState<string>('getting-started')
const [content, setContent] = useState<string>('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const doc = DOCS.find((d) => d.name === activeDoc)
if (!doc) return
setLoading(true)
setError(null)
fetch(doc.path)
.then((res) => {
if (!res.ok) throw new Error(`${res.status}`)
return res.text()
})
.then((text) => {
const stripped = text.replace(/^---[\s\S]*?---\n*/, '')
setContent(stripped)
setLoading(false)
})
.catch((err) => {
setError(`Impossible de charger ${doc.path}: ${err.message}`)
setLoading(false)
})
}, [activeDoc])
// Group docs by group
const groups = DOCS.reduce<Record<string, DocFile[]>>((acc, doc) => {
const g = doc.group || 'Autres'
if (!acc[g]) acc[g] = []
acc[g].push(doc)
return acc
}, {})
return (
<div className="flex h-full overflow-hidden">
{/* Sidebar docs */}
<div
className="flex flex-col flex-shrink-0 border-r overflow-y-auto"
style={{ width: 200, borderColor: '#2a2a2a', background: '#141414' }}
>
<div className="px-3 py-3 border-b" style={{ borderColor: '#2a2a2a' }}>
<span className="text-xs font-mono" style={{ color: '#6b7280' }}>
Documentation
</span>
</div>
<nav className="flex flex-col gap-0.5 p-2">
{Object.entries(groups).map(([group, docs]) => (
<div key={group}>
<div
className="text-xs font-mono px-3 py-1.5 mt-2"
style={{ color: '#4b5563', letterSpacing: '0.05em' }}
>
{group.toUpperCase()}
</div>
{docs.map((doc) => (
<button
key={doc.name}
onClick={() => setActiveDoc(doc.name)}
className="text-left px-3 py-1.5 rounded text-sm transition-colors w-full"
style={
activeDoc === doc.name
? { background: 'rgba(99,102,241,0.15)', color: '#818cf8' }
: { color: '#9ca3af' }
}
>
{doc.label}
</button>
))}
</div>
))}
</nav>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto" style={{ padding: '2rem 3rem' }}>
{loading && (
<div style={{ color: '#4b5563' }} className="text-sm font-mono">
Chargement...
</div>
)}
{error && (
<div style={{ color: '#ef4444' }} className="text-sm font-mono">
{error}
</div>
)}
{!loading && !error && (
<article className="docs-markdown">
<ReactMarkdown components={mdComponents}>{content}</ReactMarkdown>
</article>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface GateDrawerProps {
open: boolean
onClose: () => void
workflowId: string | null
stepId: string | null
}
export default function GateDrawer({ open, onClose, workflowId, stepId }: GateDrawerProps) {
const [busy, setBusy] = useState(false)
const [approved, setApproved] = useState(false)
// Reset state when drawer opens for a new gate
useEffect(() => {
if (open) {
setBusy(false)
setApproved(false)
}
}, [open, workflowId, stepId])
const handleApprove = async () => {
if (!workflowId || !stepId || busy) return
setBusy(true)
try {
await fetch(
`${API_BASE}/gate/${encodeURIComponent(workflowId)}/${encodeURIComponent(stepId)}/approve`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
}
)
setApproved(true)
setTimeout(() => {
setApproved(false)
onClose()
}, 1500)
} finally {
setBusy(false)
}
}
const handleReject = async () => {
if (!workflowId || !stepId || busy) return
setBusy(true)
try {
const res = await fetch(
`${API_BASE}/gate/${encodeURIComponent(workflowId)}/${encodeURIComponent(stepId)}/reject`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
}
)
// 404 = endpoint optionnel — gérer silencieusement
if (res.ok || res.status === 404) {
onClose()
}
} finally {
setBusy(false)
}
}
return (
<>
{/* Overlay — cliquable pour fermer */}
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 49,
background: open ? 'rgba(0,0,0,0.4)' : 'transparent',
pointerEvents: open ? 'auto' : 'none',
transition: 'background 0.2s',
}}
/>
{/* Panel slide-in depuis la droite */}
<div
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
zIndex: 50,
width: 380,
background: '#0a0a0a',
borderLeft: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 16px',
borderBottom: '1px solid #2a2a2a',
flexShrink: 0,
}}
>
{/* Titre */}
<span
style={{
color: '#9ca3af',
fontFamily: 'monospace',
fontSize: 12,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Gate {stepId ?? '—'}
</span>
{/* Badge "En attente d'approbation" */}
<span
style={{
fontSize: 10,
fontFamily: 'monospace',
color: '#f59e0b',
background: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.35)',
borderRadius: 4,
padding: '2px 7px',
flexShrink: 0,
}}
>
En attente d'approbation
</span>
{/* Bouton fermer */}
<button
onClick={onClose}
title="Fermer"
style={{
background: 'transparent',
border: 'none',
color: '#6b7280',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
>
</button>
</div>
{/* Corps */}
<div
style={{
flex: 1,
padding: '24px 20px',
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
{/* Description */}
<p
style={{
color: '#9ca3af',
fontSize: 13,
lineHeight: 1.6,
margin: 0,
}}
>
Cette étape est un point de contrôle. Approuver pour continuer le workflow.
</p>
{/* Métadonnées */}
{workflowId && stepId && (
<div
style={{
background: '#111',
border: '1px solid #1f1f1f',
borderRadius: 6,
padding: '10px 14px',
fontFamily: 'monospace',
fontSize: 11,
color: '#4b5563',
lineHeight: 1.7,
}}
>
<div><span style={{ color: '#374151' }}>workflow</span> {workflowId}</div>
<div><span style={{ color: '#374151' }}>step </span> {stepId}</div>
</div>
)}
{/* État "Approuvé" */}
{approved && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
color: '#22c55e',
fontSize: 14,
fontWeight: 600,
background: 'rgba(34,197,94,0.08)',
border: '1px solid rgba(34,197,94,0.25)',
borderRadius: 6,
padding: '10px 14px',
}}
>
Approuvé ✓
</div>
)}
{/* Boutons */}
{!approved && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Bouton Approuver */}
<button
disabled={busy}
onClick={handleApprove}
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid #22c55e',
color: '#22c55e',
borderRadius: 6,
padding: '10px 0',
fontSize: 13,
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
transition: 'opacity 0.15s',
width: '100%',
}}
>
{busy ? 'En cours' : 'Approuver'}
</button>
{/* Bouton Rejeter */}
<button
disabled={busy}
onClick={handleReject}
style={{
background: 'rgba(239,68,68,0.1)',
border: '1px solid #ef4444',
color: '#ef4444',
borderRadius: 6,
padding: '10px 0',
fontSize: 13,
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
transition: 'opacity 0.15s',
width: '100%',
}}
>
Rejeter
</button>
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface GatesDrawerProps {
workflowId: string
stepId: string
stepLabel: string
onApprove: () => Promise<void>
onReject: (action: 'abort' | 'skip') => Promise<void>
onClose: () => void
}
export default function GatesDrawer({
workflowId,
stepId,
stepLabel,
onApprove,
onReject,
onClose,
}: GatesDrawerProps) {
const [busy, setBusy] = useState(false)
const gateUrl = `${API_BASE}/gate/${encodeURIComponent(workflowId)}/${encodeURIComponent(stepId)}/approve`
const approve = async () => {
setBusy(true)
try {
await fetch(gateUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve' }),
})
await onApprove()
} finally {
setBusy(false)
}
}
const reject = async (action: 'abort' | 'skip') => {
setBusy(true)
try {
await fetch(gateUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
await onReject(action)
} finally {
setBusy(false)
}
}
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
background: 'rgba(245,158,11,0.15)',
borderTop: '1px solid rgba(245,158,11,0.5)',
backdropFilter: 'blur(4px)',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span style={{ color: '#fbbf24', fontWeight: 600, flex: 1 }}>
Gate en attente <span style={{ color: '#fff' }}>{stepLabel}</span>
</span>
<button
disabled={busy}
onClick={approve}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Approuver
</button>
<button
disabled={busy}
onClick={() => reject('abort')}
style={{
background: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Rejeter
</button>
<button
disabled={busy}
onClick={onClose}
style={{
background: 'transparent',
color: '#9ca3af',
border: '1px solid #374151',
borderRadius: 6,
padding: '6px 16px',
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Ignorer
</button>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useInfra } from '../hooks/useInfra'
const STATUS_DOT: Record<string, string> = {
online: '#22c55e',
stopped: '#6b7280',
errored: '#ef4444',
unknown: '#f59e0b',
}
const TYPE_BADGE: Record<string, { bg: string, color: string, label: string }> = {
pm2: { bg: 'rgba(99,102,241,0.15)', color: '#6366f1', label: 'pm2' },
system: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e', label: 'system' },
info: { bg: 'rgba(107,114,128,0.15)', color: '#6b7280', label: 'info' },
}
export default function InfraRegistry() {
const { services, loading, error, reload, formatUptime, formatMemory } = useInfra()
return (
<div style={{ padding: '24px', maxWidth: 900 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div>
<h2 style={{ color: '#e5e7eb', fontSize: 18, fontWeight: 600, margin: 0 }}>InfraRegistry</h2>
<p style={{ color: '#6b7280', fontSize: 12, margin: '4px 0 0', fontFamily: 'monospace' }}>
{loading ? 'Chargement...' : `${services.length} services`}
{error && <span style={{ color: '#ef4444', marginLeft: 8 }}> {error}</span>}
</p>
</div>
<button
onClick={reload}
disabled={loading}
style={{
marginLeft: 'auto', background: '#1a1a1a', border: '1px solid #2a2a2a',
color: '#9ca3af', borderRadius: 6, padding: '6px 12px', fontSize: 12,
cursor: loading ? 'not-allowed' : 'pointer', fontFamily: 'monospace',
}}
>
Actualiser
</button>
</div>
{/* Table */}
<div style={{ border: '1px solid #2a2a2a', borderRadius: 8, overflow: 'hidden' }}>
{/* Header row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 80px 80px 70px 70px 60px 60px',
padding: '8px 16px',
background: '#1a1a1a',
borderBottom: '1px solid #2a2a2a',
fontSize: 10, fontFamily: 'monospace', color: '#4b5563', textTransform: 'uppercase', letterSpacing: 1,
}}>
<span>Service</span>
<span>Type</span>
<span>Statut</span>
<span>Port</span>
<span>Uptime</span>
<span>Mem</span>
<span>Restarts</span>
</div>
{/* Rows */}
{services.map((svc) => {
const dot = STATUS_DOT[svc.status] ?? '#6b7280'
const badge = TYPE_BADGE[svc.type] ?? TYPE_BADGE.info
return (
<div
key={svc.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr 80px 80px 70px 70px 60px 60px',
padding: '10px 16px',
borderBottom: '1px solid #1a1a1a',
alignItems: 'center',
fontSize: 13,
}}
>
<span style={{ color: '#e5e7eb', fontWeight: 500 }}>{svc.name}</span>
<span style={{
display: 'inline-block', padding: '2px 6px', borderRadius: 4,
fontSize: 10, fontFamily: 'monospace',
background: badge.bg, color: badge.color,
}}>
{badge.label}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: dot, flexShrink: 0 }} />
<span style={{ color: dot, fontSize: 11, fontFamily: 'monospace' }}>{svc.status}</span>
</span>
<span style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{svc.port ?? '—'}
</span>
<span style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{formatUptime(svc.uptime)}
</span>
<span style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{formatMemory(svc.memory)}
</span>
<span style={{ color: svc.restarts && svc.restarts > 10 ? '#f59e0b' : '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{svc.restarts ?? '—'}
</span>
</div>
)
})}
{!loading && services.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: '#4b5563', fontFamily: 'monospace', fontSize: 12 }}>
Aucun service détecté
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useRef } from 'react'
import { useBrainStore } from '../store/brain.store'
interface LogDrawerProps {
open: boolean
onClose: () => void
project: string | null
}
const LEVEL_COLOR: Record<string, string> = {
error: '#ef4444',
warn: '#f59e0b',
info: '#9ca3af',
debug: '#4b5563',
}
const EMPTY_LOGS: never[] = []
export default function LogDrawer({ open, onClose, project }: LogDrawerProps) {
const logs = useBrainStore((s) => s.logs[project ?? ''] ?? EMPTY_LOGS)
const wsStatus = useBrainStore((s) => s.wsStatus)
const clearLogs = useBrainStore((s) => s.clearLogs)
const bottomRef = useRef<HTMLDivElement>(null)
// Auto-scroll quand nouveaux logs
useEffect(() => {
if (open) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, open])
// Badge wsStatus
const wsBadgeColor =
wsStatus === 'connected' ? '#22c55e' :
wsStatus === 'error' ? '#ef4444' : '#6b7280'
const wsLabel =
wsStatus === 'connected' ? 'ws live' :
wsStatus === 'error' ? 'ws erreur' : 'ws off'
return (
<>
{/* Overlay — cliquable pour fermer */}
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 49,
background: open ? 'rgba(0,0,0,0.4)' : 'transparent',
pointerEvents: open ? 'auto' : 'none',
transition: 'background 0.2s',
}}
/>
{/* Panel slide-in */}
<div
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
zIndex: 50,
width: 420,
background: '#0a0a0a',
borderLeft: '1px solid #1a1a1a',
display: 'flex',
flexDirection: 'column',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 16px',
borderBottom: '1px solid #1a1a1a',
flexShrink: 0,
}}
>
{/* Titre */}
<span
style={{
color: '#9ca3af',
fontFamily: 'monospace',
fontSize: 12,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Logs {project ?? '—'}
</span>
{/* Badge wsStatus */}
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 10,
fontFamily: 'monospace',
color: wsBadgeColor,
background: `${wsBadgeColor}1a`,
border: `1px solid ${wsBadgeColor}33`,
borderRadius: 4,
padding: '2px 6px',
flexShrink: 0,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: wsBadgeColor,
flexShrink: 0,
}}
/>
{wsLabel}
</span>
{/* Bouton Effacer */}
<button
onClick={() => project && clearLogs(project)}
title="Effacer les logs"
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: 4,
color: '#6b7280',
cursor: 'pointer',
fontSize: 10,
fontFamily: 'monospace',
padding: '2px 8px',
lineHeight: '16px',
flexShrink: 0,
}}
>
Effacer
</button>
{/* Bouton fermer */}
<button
onClick={onClose}
title="Fermer"
style={{
background: 'transparent',
border: 'none',
color: '#6b7280',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
>
</button>
</div>
{/* Corps — log lines */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '8px 12px',
fontFamily: 'monospace',
fontSize: 11,
}}
>
{logs.length === 0 ? (
<div style={{ color: '#4b5563', marginTop: 8, lineHeight: 1.6 }}>
Aucun log démarrer un workflow pour voir les événements.
</div>
) : (
logs.map((line, i) => (
<div key={i} style={{ marginBottom: 2, lineHeight: 1.5 }}>
<span style={{ color: '#4b5563' }}>
{line.ts.slice(11, 19)}{' '}
</span>
<span
style={{
color: LEVEL_COLOR[line.level] ?? '#9ca3af',
marginRight: 6,
}}
>
{line.level.toUpperCase().padEnd(5)}
</span>
<span style={{ color: '#d1d5db' }}>{line.msg}</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,288 @@
import { useState, useCallback } from 'react'
import { ChevronDown, ChevronRight, Eye, EyeOff, RefreshCw, Save, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'
export interface SecretKey {
key: string
label: string
status: 'filled' | 'empty' | 'missing'
canGenerate?: boolean
}
export interface SecretSection {
id: string
label: string
keys: SecretKey[]
}
interface SecretsZoneProps {
sections: SecretSection[]
onSecretSave: (section: string, key: string, value: string) => void
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function generateSecret(length = 48): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
function StatusIcon({ status }: { status: SecretKey['status'] }) {
if (status === 'filled')
return <CheckCircle2 size={14} className="text-emerald-400 shrink-0" />
if (status === 'empty')
return <AlertTriangle size={14} className="text-amber-400 shrink-0" />
return <XCircle size={14} className="text-red-500 shrink-0" />
}
function statusLabel(status: SecretKey['status']): string {
if (status === 'filled') return 'remplie'
if (status === 'empty') return 'vide'
return 'manquante'
}
// ---------------------------------------------------------------------------
// SecretRow
// ---------------------------------------------------------------------------
interface SecretRowProps {
sectionId: string
secret: SecretKey
onSave: (section: string, key: string, value: string) => void
}
function SecretRow({ sectionId, secret, onSave }: SecretRowProps) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [showValue, setShowValue] = useState(false)
const [saved, setSaved] = useState(false)
const handleGenerate = useCallback(() => {
setValue(generateSecret())
setEditing(true)
setShowValue(false)
}, [])
const handleSave = useCallback(() => {
if (!value.trim()) return
onSave(sectionId, secret.key, value)
setValue('')
setShowValue(false)
setEditing(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}, [value, sectionId, secret.key, onSave])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setValue('')
setEditing(false)
setShowValue(false)
}
},
[handleSave],
)
return (
<div className="group">
{/* Row header */}
<div
className="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer hover:bg-[#242424] transition-colors"
onClick={() => !editing && setEditing(true)}
>
<StatusIcon status={saved ? 'filled' : secret.status} />
<span className="flex-1 text-sm text-gray-300">{secret.label}</span>
<span className="text-xs text-gray-600 font-mono">{secret.key}</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
saved
? 'text-emerald-400 bg-emerald-400/10'
: secret.status === 'filled'
? 'text-emerald-400 bg-emerald-400/10'
: secret.status === 'empty'
? 'text-amber-400 bg-amber-400/10'
: 'text-red-400 bg-red-400/10'
}`}
>
{saved ? 'sauvegardée' : statusLabel(secret.status)}
</span>
</div>
{/* Inline edit */}
{editing && (
<div className="mx-3 mb-2 p-3 rounded-md bg-[#141414] border border-[#2a2a2a] space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showValue ? 'text' : 'password'}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Valeur pour ${secret.key}`}
autoFocus
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-[#6366f1] pr-9 font-mono"
/>
<button
type="button"
onClick={() => setShowValue((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
title={showValue ? 'Masquer' : 'Afficher'}
>
{showValue ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{secret.canGenerate && (
<button
type="button"
onClick={handleGenerate}
className="flex items-center gap-1 px-2 py-1.5 rounded text-xs text-indigo-400 border border-indigo-400/30 hover:bg-indigo-400/10 transition-colors whitespace-nowrap"
title="Générer un secret aléatoire"
>
<RefreshCw size={12} />
Générer
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={!value.trim()}
className="flex items-center gap-1 px-2 py-1.5 rounded text-xs text-emerald-400 border border-emerald-400/30 hover:bg-emerald-400/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
<Save size={12} />
Sauvegarder
</button>
</div>
<p className="text-xs text-gray-600">
La valeur ne sera jamais affichée en clair après sauvegarde. Appuyez sur Échap pour annuler.
</p>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SectionCard
// ---------------------------------------------------------------------------
interface SectionCardProps {
section: SecretSection
onSave: (section: string, key: string, value: string) => void
}
function SectionCard({ section, onSave }: SectionCardProps) {
const [open, setOpen] = useState(false)
const filledCount = section.keys.filter((k) => k.status === 'filled').length
const total = section.keys.length
const allFilled = filledCount === total
const hasIssues = section.keys.some((k) => k.status === 'missing')
return (
<div className="rounded-lg border border-[#2a2a2a] bg-[#1a1a1a] overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-[#212121] transition-colors text-left"
>
<span className="text-gray-400">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<span className="font-semibold text-sm text-gray-100 flex-1">{section.label}</span>
{/* Progress pill */}
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
allFilled
? 'text-emerald-400 bg-emerald-400/10'
: hasIssues
? 'text-red-400 bg-red-400/10'
: 'text-amber-400 bg-amber-400/10'
}`}
>
{filledCount}/{total}
</span>
</button>
{/* Body */}
{open && (
<div className="border-t border-[#2a2a2a] py-1">
{section.keys.map((secret) => (
<SecretRow key={secret.key} sectionId={section.id} secret={secret} onSave={onSave} />
))}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SecretsZone (root)
// ---------------------------------------------------------------------------
export const MOCK_SECTIONS: SecretSection[] = [
{
id: 'brain',
label: 'BRAIN',
keys: [
{ key: 'BRAIN_TOKEN_READ', label: 'Token lecture', status: 'filled' },
{ key: 'BRAIN_TOKEN_WRITE', label: 'Token écriture', status: 'filled' },
{ key: 'BRAIN_SERVEUR_SECRET', label: 'Secret serveur', status: 'empty', canGenerate: true },
],
},
{
id: 'vps',
label: 'VPS',
keys: [
{ key: 'VPS_IP', label: 'IP du VPS', status: 'filled' },
{ key: 'VPS_USER', label: 'Utilisateur SSH', status: 'filled' },
],
},
{
id: 'mysql',
label: 'MySQL',
keys: [
{ key: 'MYSQL_ROOT_PASSWORD', label: 'Mot de passe root', status: 'empty', canGenerate: true },
],
},
{
id: 'tetardpg',
label: 'TetaRdPG',
keys: [
{ key: 'TETARDPG_DATABASE_URL', label: 'Database URL', status: 'missing' },
{ key: 'TETARDPG_TWITCH_WEBHOOK_SECRET', label: 'Twitch Webhook Secret', status: 'missing', canGenerate: true },
{ key: 'TETARDPG_COOKIE_SECRET', label: 'Cookie Secret', status: 'missing', canGenerate: true },
],
},
{
id: 'originsdigital',
label: 'OriginsDigital',
keys: [
{ key: 'ORIGINSDIGITAL_DB_PASSWORD', label: 'DB Password', status: 'empty', canGenerate: true },
{ key: 'ORIGINSDIGITAL_JWT_SECRET', label: 'JWT Secret', status: 'missing', canGenerate: true },
],
},
]
export default function SecretsZone({ sections, onSecretSave }: SecretsZoneProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-100">Secrets</h2>
<p className="text-xs text-gray-500">Les valeurs ne sont jamais affichées en clair</p>
</div>
{sections.map((section) => (
<SectionCard key={section.id} section={section} onSave={onSecretSave} />
))}
</div>
)
}

View File

@@ -0,0 +1,128 @@
import { memo } from 'react'
import { Handle, Position, NodeProps } from 'reactflow'
import type { StepStatus } from '../types'
export interface StepNodeData {
label: string
status: StepStatus
isGate?: boolean
workflowId: string
stepId: string
onGateApprove?: (workflowId: string, stepId: string) => void
}
const STATUS_COLORS: Record<StepStatus, string> = {
done: '#22c55e',
gate: '#f59e0b',
fail: '#ef4444',
'in-progress': '#6366f1',
pending: '#2a2a2a',
partial: '#f97316',
blocked: '#6b7280',
}
const STATUS_BORDER: Record<StepStatus, string> = {
done: '#16a34a',
gate: '#d97706',
fail: '#dc2626',
'in-progress': '#4f46e5',
pending: '#3f3f3f',
partial: '#ea580c',
blocked: '#4b5563',
}
const STATUS_LABELS: Record<StepStatus, string> = {
done: 'DONE',
gate: 'GATE',
fail: 'FAIL',
'in-progress': 'IN PROGRESS',
pending: 'PENDING',
partial: 'PARTIAL',
blocked: 'BLOCKED',
}
function StepNode({ data }: NodeProps<StepNodeData>) {
const { label, status, isGate, workflowId, stepId, onGateApprove } = data
const bg = STATUS_COLORS[status]
const border = STATUS_BORDER[status]
const isClickable = isGate && (status === 'gate' || status === 'pending') && onGateApprove
const handleClick = () => {
if (isClickable) {
onGateApprove!(workflowId, stepId)
}
}
if (isGate) {
// Diamond shape via CSS transform on a square
const size = 64
return (
<>
<Handle type="target" position={Position.Top} style={{ background: border, border: 'none' }} />
<div
onClick={handleClick}
title={isClickable ? `Approve gate: ${label}` : undefined}
style={{
width: size,
height: size,
background: bg,
border: `2px solid ${border}`,
transform: 'rotate(45deg)',
cursor: isClickable ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isClickable ? `0 0 12px ${bg}88` : undefined,
transition: 'box-shadow 0.15s ease',
}}
>
<span
style={{
transform: 'rotate(-45deg)',
fontSize: 10,
fontWeight: 700,
color: '#fff',
textAlign: 'center',
lineHeight: 1.2,
userSelect: 'none',
maxWidth: 52,
wordBreak: 'break-word',
}}
>
{label}
</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ background: border, border: 'none' }} />
</>
)
}
return (
<>
<Handle type="target" position={Position.Top} style={{ background: border, border: 'none' }} />
<div
style={{
background: bg,
border: `2px solid ${border}`,
borderRadius: 8,
padding: '8px 16px',
minWidth: 120,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
cursor: 'default',
boxShadow: status === 'in-progress' ? `0 0 10px ${bg}66` : undefined,
}}
>
<span style={{ fontSize: 12, fontWeight: 700, color: '#fff', userSelect: 'none' }}>{label}</span>
<span style={{ fontSize: 9, fontWeight: 500, color: '#ffffff99', letterSpacing: 1, userSelect: 'none' }}>
{STATUS_LABELS[status]}
</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ background: border, border: 'none' }} />
</>
)
}
export default memo(StepNode)

View File

@@ -0,0 +1,122 @@
import { useState, useRef, useEffect } from 'react'
import type { TeamPreset } from '../types'
interface TeamSelectorProps {
presets: TeamPreset[]
selected: string | null
onChange: (teamId: string) => void
isLoading?: boolean
}
export default function TeamSelector({ presets, selected, onChange, isLoading }: TeamSelectorProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selectedPreset = presets.find((p) => p.id === selected) ?? null
// Fermer le dropdown si clic en dehors
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div ref={ref} className="relative">
{/* Trigger */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={isLoading}
className="flex items-center gap-2 w-full px-3 py-2 rounded text-sm text-left"
style={{
background: '#1a1a1a',
border: '1px solid #2a2a2a',
color: selectedPreset ? '#e5e7eb' : '#6b7280',
}}
>
{isLoading ? (
<span style={{ color: '#6b7280' }}>Chargement</span>
) : selectedPreset ? (
<>
<span>{selectedPreset.icon}</span>
<span className="flex-1">{selectedPreset.label}</span>
<span style={{ color: '#6b7280' }}></span>
</>
) : (
<>
<span className="flex-1">Sélectionner une équipe</span>
<span style={{ color: '#6b7280' }}></span>
</>
)}
</button>
{/* Dropdown */}
{open && (
<div
className="absolute z-50 w-full mt-1 rounded overflow-hidden"
style={{ background: '#1a1a1a', border: '1px solid #2a2a2a', top: '100%' }}
>
{presets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => { onChange(preset.id); setOpen(false) }}
className="flex flex-col w-full px-3 py-2 text-left"
style={{
background: preset.id === selected ? 'rgba(99,102,241,0.15)' : 'transparent',
borderLeft: preset.id === selected ? '2px solid #6366f1' : '2px solid transparent',
color: '#e5e7eb',
}}
>
{/* Header */}
<div className="flex items-center gap-2 text-sm font-medium">
<span>{preset.icon}</span>
<span>{preset.label}</span>
{preset.gate_required && (
<span
className="text-xs px-1 rounded font-mono"
style={{ background: '#292524', color: '#f59e0b' }}
>
gate
</span>
)}
</div>
{/* Preview agents */}
<div className="flex flex-wrap gap-1 mt-1">
{preset.agents.slice(0, 4).map((agent) => (
<span
key={agent}
className="text-xs px-1 rounded font-mono"
style={{ background: '#0d0d0d', color: '#9ca3af' }}
>
{agent}
</span>
))}
{preset.agents.length > 4 && (
<span className="text-xs" style={{ color: '#4b5563' }}>
+{preset.agents.length - 4}
</span>
)}
</div>
{/* Capabilities */}
<div className="flex gap-1 mt-1">
{preset.capabilities.slice(0, 5).map((cap) => (
<span key={cap} className="text-xs" style={{ color: '#4b5563' }}>
{cap}
</span>
))}
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface TierGateProps {
feature: string
hasFeature: (f: string) => boolean
fallback?: ReactNode
children: ReactNode
}
export default function TierGate({ feature, hasFeature, fallback, children }: TierGateProps) {
if (!hasFeature(feature)) {
return fallback ? <>{fallback}</> : (
<div className="flex flex-col items-center justify-center h-full" style={{ color: '#4b5563' }}>
<div className="text-3xl mb-3">🔒</div>
<div className="text-sm font-medium">Fonctionnalité non disponible</div>
<div className="text-xs mt-1 font-mono" style={{ color: '#374151' }}>{feature} tier insuffisant</div>
</div>
)
}
return <>{children}</>
}

View File

@@ -0,0 +1,211 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
// ─── Types ───────────────────────────────────────────────────────────────────
export interface Toast {
id: string
message: string
level: 'info' | 'warn' | 'error' | 'success'
context?: string
}
interface ToastContextValue {
addToast: (message: string, level: Toast['level'], context?: string) => void
}
// ─── Context ─────────────────────────────────────────────────────────────────
const ToastContext = createContext<ToastContextValue | null>(null)
// ─── Level → border color ────────────────────────────────────────────────────
const LEVEL_COLOR: Record<Toast['level'], string> = {
info: '#6366f1',
warn: '#f59e0b',
error: '#ef4444',
success: '#22c55e',
}
const DISMISS_DELAY: Record<Toast['level'], number> = {
info: 4000,
success: 4000,
warn: 7000,
error: 7000,
}
const MAX_VISIBLE = 4
// ─── ToastItem ────────────────────────────────────────────────────────────────
interface ToastItemProps {
toast: Toast
onDismiss: (id: string) => void
}
function ToastItem({ toast, onDismiss }: ToastItemProps) {
const [visible, setVisible] = useState(false)
// Slide-in on mount
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true))
return () => cancelAnimationFrame(raf)
}, [])
const handleDismiss = () => {
setVisible(false)
setTimeout(() => onDismiss(toast.id), 220)
}
const borderColor = LEVEL_COLOR[toast.level]
return (
<div
style={{
background: '#0a0a0a',
border: `1px solid ${borderColor}`,
borderRadius: 6,
padding: '10px 14px',
minWidth: 280,
maxWidth: 380,
fontFamily: 'monospace',
fontSize: 12,
color: '#e5e7eb',
display: 'flex',
alignItems: 'flex-start',
gap: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
transform: visible ? 'translateX(0)' : 'translateX(110%)',
transition: 'transform 200ms ease, opacity 200ms ease',
opacity: visible ? 1 : 0,
cursor: 'default',
}}
>
{/* Level dot */}
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: borderColor,
flexShrink: 0,
marginTop: 3,
}}
/>
{/* Content */}
<div style={{ flex: 1, lineHeight: 1.5 }}>
{toast.context && (
<span style={{ color: borderColor, marginRight: 6, fontSize: 10 }}>
[{toast.context}]
</span>
)}
{toast.message}
</div>
{/* Dismiss button */}
<button
onClick={handleDismiss}
aria-label="Fermer"
style={{
background: 'transparent',
border: 'none',
color: '#4b5563',
cursor: 'pointer',
fontSize: 14,
lineHeight: 1,
padding: 0,
flexShrink: 0,
}}
>
</button>
</div>
)
}
// ─── ToastProvider ────────────────────────────────────────────────────────────
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
const timer = timersRef.current.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timersRef.current.delete(id)
}
}, [])
const addToast = useCallback(
(message: string, level: Toast['level'], context?: string) => {
const id = Date.now().toString()
const toast: Toast = { id, message, level, context }
setToasts((prev) => {
const next = [...prev, toast]
// Keep only the last MAX_VISIBLE toasts
return next.slice(-MAX_VISIBLE)
})
const delay = DISMISS_DELAY[level]
const timer = setTimeout(() => removeToast(id), delay)
timersRef.current.set(id, timer)
},
[removeToast],
)
// Cleanup all timers on unmount
useEffect(() => {
const timers = timersRef.current
return () => {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
}
}, [])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{/* Toast container */}
<div
style={{
position: 'fixed',
bottom: 16,
right: 16,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
gap: 8,
pointerEvents: 'none',
}}
>
{toasts.map((toast) => (
<div key={toast.id} style={{ pointerEvents: 'auto' }}>
<ToastItem toast={toast} onDismiss={removeToast} />
</div>
))}
</div>
</ToastContext.Provider>
)
}
// ─── useToast ─────────────────────────────────────────────────────────────────
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (!ctx) {
throw new Error('useToast must be used inside <ToastProvider>')
}
return ctx
}

View File

@@ -0,0 +1,208 @@
import 'reactflow/dist/style.css'
import ReactFlow, {
ReactFlowProvider,
Node,
Edge,
Background,
BackgroundVariant,
Controls,
MiniMap,
useNodesState,
useEdgesState,
} from 'reactflow'
import { useMemo } from 'react'
import type { Workflow } from '../types'
import StepNode, { StepNodeData } from './StepNode'
// ─── Mock data ───────────────────────────────────────────────────────────────
export const MOCK_WORKFLOWS: Workflow[] = [
{
id: 'clk',
name: 'Clickerz Sprint 2',
project: 'clickerz',
steps: [
{ id: 'init', label: 'INIT', status: 'done' },
{ id: 's1', label: 'UI Components', status: 'in-progress' },
{ id: 's2', label: 'Tests', status: 'pending' },
{ id: 'deploy', label: 'Deploy', status: 'pending', isGate: true },
],
},
{
id: 'od',
name: 'OriginsDigital Sprint 4',
project: 'originsdigital',
steps: [
{ id: 'init', label: 'INIT', status: 'done' },
{ id: 's1', label: 'SuperOAuth SDK', status: 'gate', isGate: true },
{ id: 's2', label: 'Auth Flow', status: 'blocked' },
{ id: 'deploy', label: 'Deploy', status: 'blocked', isGate: true },
],
},
]
// ─── Layout constants ─────────────────────────────────────────────────────────
const COL_WIDTH = 220 // horizontal spacing between workflow columns
const ROW_HEIGHT = 110 // vertical spacing between steps
const COL_OFFSET_X = 80 // left margin
const ROW_OFFSET_Y = 60 // top margin
const GATE_NODE_SIZE = 68 // diamond bounding box — must match StepNode size
// ─── Node type registry ───────────────────────────────────────────────────────
const nodeTypes = { stepNode: StepNode }
// ─── Builder helpers ──────────────────────────────────────────────────────────
function buildNodesAndEdges(
workflows: Workflow[],
onGateApprove: (wfId: string, stepId: string) => void
): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
workflows.forEach((wf, colIdx) => {
if (!wf.steps?.length) return
const x = COL_OFFSET_X + colIdx * COL_WIDTH
wf.steps.forEach((step, rowIdx) => {
const y = ROW_OFFSET_Y + rowIdx * ROW_HEIGHT
const nodeId = `${wf.id}__${step.id}`
const data: StepNodeData = {
label: step.label,
status: step.status,
isGate: step.isGate,
workflowId: wf.id,
stepId: step.id,
onGateApprove,
}
nodes.push({
id: nodeId,
type: 'stepNode',
position: { x, y },
data,
// Gate nodes are diamond — center them the same as rect nodes
style: step.isGate
? { width: GATE_NODE_SIZE, height: GATE_NODE_SIZE }
: undefined,
})
// Edge from previous step to this one
if (rowIdx > 0) {
const prevNodeId = `${wf.id}__${wf.steps[rowIdx - 1].id}`
edges.push({
id: `e_${prevNodeId}_${nodeId}`,
source: prevNodeId,
target: nodeId,
animated: wf.steps[rowIdx - 1].status === 'in-progress',
style: { stroke: '#555', strokeWidth: 1.5 },
})
}
})
})
return { nodes, edges }
}
// ─── Inner board (needs ReactFlow context) ────────────────────────────────────
interface BoardInnerProps {
workflows: Workflow[]
onGateApprove: (wfId: string, stepId: string) => void
onWorkflowClick?: (wfId: string) => void
}
function BoardInner({ workflows, onGateApprove, onWorkflowClick }: BoardInnerProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => buildNodesAndEdges(workflows, onGateApprove),
[workflows, onGateApprove]
)
const [nodes, , onNodesChange] = useNodesState(initialNodes)
const [edges, , onEdgesChange] = useEdgesState(initialEdges)
// Column headers — rendered as workflow name labels above the first node
const headerNodes: Node[] = useMemo(
() =>
workflows.map((wf, colIdx) => ({
id: `header__${wf.id}`,
type: 'default',
position: { x: COL_OFFSET_X + colIdx * COL_WIDTH - 10, y: 10 },
data: { label: wf.name },
style: {
background: 'transparent',
border: 'none',
fontSize: 11,
fontWeight: 700,
color: '#aaa',
letterSpacing: 0.5,
pointerEvents: 'all',
cursor: 'pointer',
width: 180,
},
selectable: false,
draggable: false,
})),
[workflows]
)
const allNodes = useMemo(() => [...headerNodes, ...nodes], [headerNodes, nodes])
return (
<div style={{ width: '100%', height: '100%', background: '#111' }}>
<ReactFlow
nodes={allNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
onNodeClick={(_e, node) => {
if (onWorkflowClick && node.id.startsWith('header__')) {
onWorkflowClick(node.id.replace('header__', ''))
}
}}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background color="#222" variant={BackgroundVariant.Dots} gap={24} size={1} />
<Controls style={{ background: '#1a1a1a', border: '1px solid #333', color: '#aaa' }} />
<MiniMap
style={{ background: '#1a1a1a', border: '1px solid #333' }}
nodeColor={(n) => {
const d = n.data as StepNodeData | undefined
if (!d?.status) return '#333'
const map: Record<string, string> = {
done: '#22c55e', gate: '#f59e0b', fail: '#ef4444',
'in-progress': '#6366f1', pending: '#2a2a2a',
partial: '#f97316', blocked: '#6b7280',
}
return map[d.status] ?? '#333'
}}
maskColor="#11111188"
/>
</ReactFlow>
</div>
)
}
// ─── Public component ─────────────────────────────────────────────────────────
export interface WorkflowBoardProps {
workflows: Workflow[]
onGateApprove: (wfId: string, stepId: string) => void
onWorkflowClick?: (wfId: string) => void
}
export default function WorkflowBoard({ workflows, onGateApprove, onWorkflowClick }: WorkflowBoardProps) {
return (
<ReactFlowProvider>
<BoardInner workflows={workflows} onGateApprove={onGateApprove} onWorkflowClick={onWorkflowClick} />
</ReactFlowProvider>
)
}

View File

@@ -0,0 +1,283 @@
import { useState } from 'react'
import type { StepDraft, WorkflowDraft } from '../types'
import TeamSelector from './TeamSelector'
import { useTeams } from '../hooks/useTeams'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
function makeId() {
return Math.random().toString(36).slice(2, 8)
}
export default function WorkflowBuilder() {
const { teams, isLoading: teamsLoading } = useTeams()
const [title, setTitle] = useState('')
const [teamId, setTeamId] = useState<string | null>(null)
const [steps, setSteps] = useState<StepDraft[]>([
{ id: makeId(), label: '', type: 'step' },
])
const [gateRequired, setGateRequired] = useState(false)
const [sending, setSending] = useState(false)
const [result, setResult] = useState<{ ok: boolean; claimId?: string; error?: string } | null>(null)
// Sync gateRequired depuis le preset sélectionné
const handleTeamChange = (id: string) => {
setTeamId(id)
const preset = teams.find((t) => t.id === id)
if (preset) setGateRequired(preset.gate_required)
setResult(null)
}
const addStep = (type: 'step' | 'gate') => {
setSteps((prev) => [...prev, { id: makeId(), label: '', type }])
setResult(null)
}
const updateStep = (id: string, label: string) => {
setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, label } : s)))
}
const removeStep = (id: string) => {
setSteps((prev) => prev.filter((s) => s.id !== id))
}
const moveStep = (id: string, dir: -1 | 1) => {
setSteps((prev) => {
const idx = prev.findIndex((s) => s.id === id)
if (idx < 0) return prev
const next = idx + dir
if (next < 0 || next >= prev.length) return prev
const arr = [...prev]
;[arr[idx], arr[next]] = [arr[next], arr[idx]]
return arr
})
}
const canSend = title.trim().length > 0 && teamId !== null && steps.some((s) => s.label.trim())
const handleSend = async () => {
if (!canSend) return
setSending(true)
setResult(null)
const draft: WorkflowDraft = {
title: title.trim(),
teamId: teamId!,
steps: steps.filter((s) => s.label.trim()),
gateRequired,
}
if (USE_MOCK || !API_BASE) {
// Simulation locale
await new Promise((r) => setTimeout(r, 600))
const fakeId = `sess-mock-${Date.now()}`
setResult({ ok: true, claimId: fakeId })
setSending(false)
return
}
try {
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const resp = await fetch(`${API_BASE}/workflows/create`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(draft),
})
const data = await resp.json()
if (resp.ok && data.ok) {
setResult({ ok: true, claimId: data.claimId })
} else {
setResult({ ok: false, error: data.error ?? 'Erreur inconnue' })
}
} catch (e) {
setResult({ ok: false, error: 'Impossible de joindre le kernel' })
} finally {
setSending(false)
}
}
return (
<div className="flex flex-col gap-6 p-6 max-w-2xl">
<div>
<h2 className="text-lg font-semibold text-white mb-1">Nouveau workflow</h2>
<p className="text-sm" style={{ color: '#6b7280' }}>
Configure et envoie un workflow au kernel brain.
</p>
</div>
{/* Titre */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: '#9ca3af' }}>
Titre
</label>
<input
autoFocus
type="text"
value={title}
onChange={(e) => { setTitle(e.target.value); setResult(null) }}
placeholder="ex: Clickerz Sprint 2 — Zustand + Gates"
className="px-3 py-2 rounded text-sm text-white placeholder-gray-600 outline-none"
style={{ background: '#1a1a1a', border: '1px solid #2a2a2a' }}
/>
</div>
{/* Team preset */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: '#9ca3af' }}>
Équipe
</label>
<TeamSelector
presets={teams}
selected={teamId}
onChange={handleTeamChange}
isLoading={teamsLoading}
/>
</div>
{/* Steps */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: '#9ca3af' }}>
Étapes
</label>
<div className="flex flex-col gap-1">
{steps.map((step, idx) => (
<div key={step.id} className="flex items-center gap-2">
{/* Move */}
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveStep(step.id, -1)}
disabled={idx === 0}
className="text-xs leading-none px-1"
style={{ color: idx === 0 ? '#374151' : '#6b7280' }}
>
</button>
<button
type="button"
onClick={() => moveStep(step.id, 1)}
disabled={idx === steps.length - 1}
className="text-xs leading-none px-1"
style={{ color: idx === steps.length - 1 ? '#374151' : '#6b7280' }}
>
</button>
</div>
{/* Type badge */}
<span
className="text-xs px-1.5 py-0.5 rounded font-mono w-10 text-center flex-shrink-0"
style={
step.type === 'gate'
? { background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }
: { background: '#1a1a1a', color: '#6b7280' }
}
>
{step.type === 'gate' ? 'gate' : 'step'}
</span>
{/* Label input */}
<input
type="text"
value={step.label}
onChange={(e) => updateStep(step.id, e.target.value)}
placeholder={step.type === 'gate' ? 'ex: Review humain' : 'ex: Setup Zustand store'}
className="flex-1 px-2 py-1.5 rounded text-sm text-white placeholder-gray-600 outline-none"
style={{ background: '#1a1a1a', border: '1px solid #2a2a2a' }}
/>
{/* Remove */}
{steps.length > 1 && (
<button
type="button"
onClick={() => removeStep(step.id)}
className="text-xs px-1"
style={{ color: '#4b5563' }}
>
</button>
)}
</div>
))}
</div>
{/* Add step / gate */}
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={() => addStep('step')}
className="text-xs px-2 py-1 rounded"
style={{ background: '#1a1a1a', color: '#9ca3af', border: '1px solid #2a2a2a' }}
>
+ step
</button>
<button
type="button"
onClick={() => addStep('gate')}
className="text-xs px-2 py-1 rounded"
style={{ background: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.2)' }}
>
+ gate
</button>
</div>
</div>
{/* Gate required toggle */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => { setGateRequired((v) => !v); setResult(null) }}
className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
style={{ background: gateRequired ? '#6366f1' : '#2a2a2a' }}
>
<span
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full transition-transform"
style={{
background: '#fff',
transform: gateRequired ? 'translateX(20px)' : 'translateX(0)',
}}
/>
</button>
<span className="text-sm" style={{ color: '#9ca3af' }}>
Gate humaine requise avant exécution
</span>
</div>
{/* Submit */}
<div className="flex items-center gap-4">
<button
type="button"
onClick={handleSend}
disabled={!canSend || sending}
className="flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-opacity"
style={{
background: canSend && !sending ? '#6366f1' : '#2a2a2a',
color: canSend && !sending ? '#fff' : '#4b5563',
cursor: canSend && !sending ? 'pointer' : 'not-allowed',
}}
>
{sending ? 'Envoi…' : 'Envoyer au kernel ▶'}
</button>
{result && (
<div
className="text-sm px-3 py-1.5 rounded"
style={
result.ok
? { background: 'rgba(34,197,94,0.1)', color: '#22c55e' }
: { background: 'rgba(239,68,68,0.1)', color: '#ef4444' }
}
>
{result.ok ? `✓ Claim créé : ${result.claimId}` : `${result.error}`}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import type { ZoneKey } from '../../types'
type ZoneFilter = 'all' | ZoneKey
interface ZoneOption {
id: ZoneFilter
label: string
color: string
}
const ZONE_OPTIONS: ZoneOption[] = [
{ id: 'all', label: 'Tout', color: '#9ca3af' },
{ id: 'kernel', label: 'kernel', color: '#ef4444' },
{ id: 'instance', label: 'instance', color: '#f59e0b' },
{ id: 'satellite', label: 'satellite', color: '#6366f1' },
{ id: 'public', label: 'public', color: '#e5e7eb' },
]
interface CosmosControlsProps {
activeZone: ZoneFilter
searchQuery: string
onZoneChange: (zone: ZoneFilter) => void
onSearchChange: (query: string) => void
isFullscreen: boolean
onToggleFullscreen: () => void
isHeatmap: boolean
onToggleHeatmap: () => void
}
export function CosmosControls({ activeZone, searchQuery, onZoneChange, onSearchChange, isFullscreen, onToggleFullscreen, isHeatmap, onToggleHeatmap }: CosmosControlsProps) {
return (
<div
className="flex items-center gap-2 flex-shrink-0"
style={{
padding: '8px 12px',
borderBottom: '1px solid #2a2a2a',
background: '#0d0d0d',
}}
>
<div className="flex items-center gap-1">
{ZONE_OPTIONS.map((opt) => {
const isActive = activeZone === opt.id
return (
<button
key={opt.id}
onClick={() => onZoneChange(opt.id)}
className="text-xs px-2.5 py-1 rounded font-mono transition-colors"
style={{
background: isActive ? 'rgba(99,102,241,0.15)' : 'transparent',
color: isActive ? opt.color : '#6b7280',
border: `1px solid ${isActive ? opt.color : '#2a2a2a'}`,
}}
>
{opt.label}
</button>
)
})}
</div>
<div className="flex-1" />
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Rechercher..."
className="text-xs font-mono rounded px-2.5 py-1 outline-none"
style={{
background: '#1a1a1a',
border: '1px solid #2a2a2a',
color: '#e5e7eb',
width: 200,
}}
/>
<button
onClick={onToggleHeatmap}
title={isHeatmap ? 'Mode points' : 'Mode nébuleuse'}
className="text-xs font-mono rounded px-2 py-1 transition-colors"
style={{
background: isHeatmap ? 'rgba(99,102,241,0.15)' : 'transparent',
border: `1px solid ${isHeatmap ? '#6366f1' : '#2a2a2a'}`,
color: isHeatmap ? '#818cf8' : '#6b7280',
lineHeight: 1,
flexShrink: 0,
}}
>
</button>
<button
onClick={onToggleFullscreen}
title={isFullscreen ? 'Quitter le plein écran' : 'Plein écran'}
className="text-xs font-mono rounded px-2 py-1 transition-colors"
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
color: '#6b7280',
lineHeight: 1,
flexShrink: 0,
}}
>
{isFullscreen ? '⊠' : '⊡'}
</button>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import { useState } from 'react'
import type { CosmosPoint, ZoneKey } from '../../types'
const ZONE_BADGE_COLORS: Record<ZoneKey, { bg: string; text: string }> = {
public: { bg: 'rgba(229,231,235,0.1)', text: '#e5e7eb' },
work: { bg: 'rgba(99,102,241,0.15)', text: '#6366f1' },
kernel: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444' },
unknown: { bg: 'rgba(75,85,99,0.2)', text: '#6b7280' },
}
function getNearestNeighbors(target: CosmosPoint, all: CosmosPoint[], n = 10): CosmosPoint[] {
return all
.filter((p) => p.id !== target.id)
.map((p) => ({
point: p,
dist: Math.sqrt(
(p.x - target.x) ** 2 +
(p.y - target.y) ** 2 +
(p.z - target.z) ** 2
),
}))
.sort((a, b) => a.dist - b.dist)
.slice(0, n)
.map((e) => e.point)
}
interface CosmosInfoPanelProps {
point: CosmosPoint | null
allPoints: CosmosPoint[]
onClose: () => void
onHighlightNeighbors: (ids: Set<string>) => void
highlightedIds: Set<string>
kernelAccess?: boolean
}
export function CosmosInfoPanel({ point, allPoints, onClose, onHighlightNeighbors, highlightedIds, kernelAccess }: CosmosInfoPanelProps) {
const [neighborsActive, setNeighborsActive] = useState(false)
const [editing, setEditing] = useState(false)
const [draftContent, setDraftContent] = useState('')
const [saving, setSaving] = useState(false)
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const isOpen = point !== null
const handleToggleNeighbors = () => {
if (!point) return
if (neighborsActive) {
onHighlightNeighbors(new Set())
setNeighborsActive(false)
} else {
const neighbors = getNearestNeighbors(point, allPoints, 10)
onHighlightNeighbors(new Set(neighbors.map((p) => p.id)))
setNeighborsActive(true)
}
}
// Reset neighbors active state when point changes
const handleClose = () => {
setNeighborsActive(false)
setEditing(false)
onHighlightNeighbors(new Set())
onClose()
}
const handleSave = async () => {
if (!point) return
setSaving(true)
try {
await fetch(`${API_BASE}/brain/${encodeURIComponent(point.path)}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: draftContent }),
})
setEditing(false)
} catch {
// silencieux — pas de connexion
} finally {
setSaving(false)
}
}
const badgeColors = point ? ZONE_BADGE_COLORS[point.zone] : ZONE_BADGE_COLORS.unknown
return (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 320,
background: '#0d0d0d',
borderLeft: '1px solid #2a2a2a',
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 200ms ease',
zIndex: 20,
display: 'flex',
flexDirection: 'column',
padding: '16px',
overflowY: 'auto',
}}
>
{point && (
<>
{/* Close button */}
<div className="flex justify-end mb-4">
<button
onClick={handleClose}
className="text-xs px-2 py-1 rounded"
style={{ color: '#6b7280', background: 'transparent', border: '1px solid #2a2a2a' }}
>
</button>
</div>
{/* Path */}
<div
className="font-mono text-xs mb-2 break-all"
style={{ color: '#6b7280' }}
>
{point.path}
</div>
{/* Zone badge */}
<div className="mb-3">
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{ background: badgeColors.bg, color: badgeColors.text }}
>
{point.zone}
</span>
</div>
{/* Label */}
<div
className="text-base font-semibold mb-3"
style={{ color: '#e5e7eb' }}
>
{point.label}
</div>
{/* Separator */}
<div style={{ borderTop: '1px solid #2a2a2a', marginBottom: 12 }} />
{/* Excerpt / Editor */}
{editing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<textarea
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
style={{
background: '#1a1a1a', border: '1px solid #2a2a2a', color: '#e5e7eb',
borderRadius: 6, padding: 8, fontSize: 12, fontFamily: 'monospace',
resize: 'vertical', minHeight: 120, outline: 'none',
}}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleSave} disabled={saving}
style={{ background: '#6366f1', color: '#fff', border: 'none', borderRadius: 6, padding: '4px 12px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.6 : 1 }}>
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
<button onClick={() => setEditing(false)}
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #2a2a2a', borderRadius: 6, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}>
Annuler
</button>
</div>
</div>
) : (
<div className="mb-4">
<p style={{ color: '#9ca3af', fontSize: 14, lineHeight: 1.6, marginBottom: 8 }}>{point.excerpt}</p>
{kernelAccess && (
<button
onClick={() => { setEditing(true); setDraftContent(point.excerpt) }}
style={{ background: '#1a1a1a', color: '#6366f1', border: '1px solid #2a2a2a', borderRadius: 6, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
>
Modifier
</button>
)}
</div>
)}
{/* Separator */}
<div style={{ borderTop: '1px solid #2a2a2a', marginBottom: 12 }} />
{/* Neighbors button */}
<button
onClick={handleToggleNeighbors}
className="text-xs px-3 py-2 rounded text-left"
style={{
background: neighborsActive ? 'rgba(99,102,241,0.15)' : '#1a1a1a',
color: neighborsActive ? '#6366f1' : '#e5e7eb',
border: `1px solid ${neighborsActive ? '#6366f1' : '#2a2a2a'}`,
}}
>
{neighborsActive ? 'Réinitialiser les voisins' : 'Voir les 10 voisins'}
</button>
{highlightedIds.size > 0 && (
<div
className="text-xs mt-2 font-mono"
style={{ color: '#6b7280' }}
>
{highlightedIds.size} points mis en surbrillance
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import type { CosmosPoint, ZoneKey } from '../../types'
const ZONE_TEXT_COLORS: Record<ZoneKey, string> = {
kernel: '#ef4444',
instance: '#f59e0b',
satellite: '#6366f1',
public: '#e5e7eb',
work: '#6366f1',
unknown: '#6b7280',
}
interface CosmosMetricsProps {
points: CosmosPoint[]
generatedAt: string | null
onReload: () => void
loading: boolean
}
export function CosmosMetrics({ points, generatedAt, onReload, loading }: CosmosMetricsProps) {
const { total, byZone, lastSync } = useMemo(() => {
const total = points.length
const byZone = points.reduce((acc, p) => {
acc[p.zone] = (acc[p.zone] ?? 0) + 1
return acc
}, {} as Partial<Record<ZoneKey, number>>)
const lastSync = generatedAt
? new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(generatedAt))
: '—'
return { total, byZone, lastSync }
}, [points, generatedAt])
const zones: ZoneKey[] = ['kernel', 'instance', 'satellite', 'public']
return (
<div
className="flex items-center gap-3 flex-shrink-0 px-3"
style={{
height: 40,
background: '#0d0d0d',
borderTop: '1px solid #2a2a2a',
fontSize: 11,
fontFamily: 'monospace',
color: '#6b7280',
}}
>
<span>Total : {total}</span>
<span style={{ color: '#2a2a2a' }}>|</span>
{zones.map((zone) => (
byZone[zone] != null ? (
<span key={zone}>
<span style={{ color: ZONE_TEXT_COLORS[zone] }}>{zone}</span>
{' : '}
<span style={{ color: '#9ca3af' }}>{byZone[zone]}</span>
</span>
) : null
))}
<span style={{ color: '#2a2a2a' }}>|</span>
<span>sync : {lastSync}</span>
<div className="flex-1" />
<button
onClick={onReload}
disabled={loading}
className="text-xs px-2 py-1 rounded"
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
color: loading ? '#4b5563' : '#9ca3af',
cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'monospace',
fontSize: 11,
}}
>
{loading ? '...' : '⟳ Recharger'}
</button>
</div>
)
}

View File

@@ -0,0 +1,150 @@
import { useRef, useMemo, useCallback } from 'react'
import { useThree } from '@react-three/fiber'
import * as THREE from 'three'
import type { CosmosPoint, ZoneKey } from '../../types'
export const ZONE_COLORS: Record<ZoneKey, [number, number, number]> = {
kernel: [0.937, 0.267, 0.267], // rouge — protection maximale
instance: [1.000, 0.600, 0.200], // orange — config machine
satellite: [0.388, 0.400, 0.945], // bleu — satellites autonomes
public: [0.898, 0.906, 0.922], // blanc — visible, distribué
work: [0.388, 0.400, 0.945], // bleu (compat legacy)
unknown: [0.294, 0.337, 0.369], // gris
}
interface CosmosPointsProps {
points: CosmosPoint[]
activeZone: 'all' | ZoneKey
highlightedIds: Set<string>
onPointClick: (point: CosmosPoint) => void
heatmap?: boolean
}
export function CosmosPoints({ points, activeZone, highlightedIds, onPointClick, heatmap = false }: CosmosPointsProps) {
const pointsRef = useRef<THREE.Points>(null)
const { camera, raycaster, gl } = useThree()
const { positions, colors } = useMemo(() => {
const positions = new Float32Array(points.length * 3)
const colors = new Float32Array(points.length * 3)
// Normalise les coords UMAP vers [-2, 2] centrées à l'origine
// Centre de masse (mean) — robuste aux outliers qui décalent le bounding box
const xs = points.map((p) => p.x)
const ys = points.map((p) => p.y)
const zs = points.map((p) => p.z)
const n = points.length || 1
const cx = xs.reduce((a, b) => a + b, 0) / n
const cy = ys.reduce((a, b) => a + b, 0) / n
const cz = zs.reduce((a, b) => a + b, 0) / n
// Scale sur percentile 95 — les outliers ne déforment plus la nébuleuse
const dists = points.map((p) =>
Math.max(Math.abs(p.x - cx), Math.abs(p.y - cy), Math.abs(p.z - cz))
).sort((a, b) => a - b)
const p95 = dists[Math.floor(n * 0.95)] ?? dists[dists.length - 1] ?? 1
const scale = 2 / Math.max(p95, 0.001)
points.forEach((p, i) => {
positions[i * 3] = (p.x - cx) * scale
positions[i * 3 + 1] = (p.y - cy) * scale
positions[i * 3 + 2] = (p.z - cz) * scale
const [r, g, b] = ZONE_COLORS[p.zone] ?? ZONE_COLORS.unknown
if (heatmap) {
// Mode nébuleuse — couleur pleine, l'alpha est géré dans le fragment shader
const dimmed = activeZone !== 'all' && p.zone !== activeZone ? 0.15 : 1.0
colors[i * 3] = r * dimmed
colors[i * 3 + 1] = g * dimmed
colors[i * 3 + 2] = b * dimmed
} else {
let alpha = 1.0
if (activeZone !== 'all' && p.zone !== activeZone) {
alpha = 0.08
} else if (highlightedIds.size > 0 && !highlightedIds.has(p.id)) {
alpha = 0.05
}
colors[i * 3] = r * alpha
colors[i * 3 + 1] = g * alpha
colors[i * 3 + 2] = b * alpha
}
})
return { positions, colors }
}, [points, activeZone, highlightedIds])
const handleClick = useCallback((event: { nativeEvent: MouseEvent }) => {
if (!pointsRef.current) return
const nativeEvent = event.nativeEvent
const rect = gl.domElement.getBoundingClientRect()
const mouse = new THREE.Vector2(
((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1,
-((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1,
)
raycaster.setFromCamera(mouse, camera)
raycaster.params.Points = { threshold: 0.05 }
const intersects = raycaster.intersectObject(pointsRef.current)
if (intersects.length > 0 && intersects[0].index != null) {
const idx = intersects[0].index
onPointClick(points[idx])
}
}, [points, camera, raycaster, gl, onPointClick])
return (
<points ref={pointsRef} onClick={handleClick}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
{heatmap ? (
<shaderMaterial
vertexShader={`
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = clamp(60.0 / -mvPosition.z, 10.0, 50.0);
gl_Position = projectionMatrix * mvPosition;
}
`}
fragmentShader={`
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - vec2(0.5);
float d = dot(uv, uv);
if (d > 0.25) discard;
float alpha = 0.25 * (1.0 - d * 3.0);
gl_FragColor = vec4(vColor, alpha);
}
`}
transparent={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
) : (
<shaderMaterial
vertexShader={`
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = clamp(12.0 / -mvPosition.z, 1.5, 5.0);
gl_Position = projectionMatrix * mvPosition;
}
`}
fragmentShader={`
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - vec2(0.5);
if (dot(uv, uv) > 0.25) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`}
transparent={false}
/>
)}
</points>
)
}

View File

@@ -0,0 +1,41 @@
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { CosmosPoints } from './CosmosPoints'
import type { CosmosPoint, ZoneKey } from '../../types'
interface CosmosSceneProps {
points: CosmosPoint[]
activeZone: 'all' | ZoneKey
highlightedIds: Set<string>
onPointClick: (point: CosmosPoint) => void
heatmap?: boolean
}
export function CosmosScene({ points, activeZone, highlightedIds, onPointClick, heatmap }: CosmosSceneProps) {
return (
<Canvas
style={{ height: '100%', background: '#080808' }}
camera={{ position: [0, 0, 5], fov: 60 }}
gl={{ antialias: false }}
onCreated={({ gl }) => {
gl.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}}
>
<ambientLight intensity={0.3} />
<CosmosPoints
points={points}
activeZone={activeZone}
highlightedIds={highlightedIds}
onPointClick={onPointClick}
heatmap={heatmap}
/>
<OrbitControls
enableDamping={true}
dampingFactor={0.05}
rotateSpeed={0.5}
autoRotate={heatmap}
autoRotateSpeed={0.4}
/>
</Canvas>
)
}

View File

@@ -0,0 +1,178 @@
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
function checkWebGL(): boolean {
try {
const canvas = document.createElement('canvas')
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
} catch { return false }
}
import { useCosmosData } from '../../hooks/useCosmosData'
import { CosmosScene } from './CosmosScene'
import { CosmosControls } from './CosmosControls'
import { CosmosInfoPanel } from './CosmosInfoPanel'
import { CosmosMetrics } from './CosmosMetrics'
import type { CosmosPoint, ZoneKey } from '../../types'
type ZoneFilter = 'all' | ZoneKey
function NoWebGL() {
return (
<div className="flex flex-col items-center justify-center h-full" style={{ background: '#080808' }}>
<div className="text-3xl mb-3">🖥</div>
<div style={{ color: '#ef4444' }} className="text-sm font-mono mb-1">WebGL non disponible</div>
<div style={{ color: '#4b5563' }} className="text-xs text-center max-w-xs">
Active l'accélération matérielle dans Chrome : Paramètres → Système → Utiliser l'accélération matérielle
</div>
</div>
)
}
function CosmosInner() {
const { points, loading, error, generatedAt, reload } = useCosmosData()
const [selectedPoint, setSelectedPoint] = useState<CosmosPoint | null>(null)
const [activeZone, setActiveZone] = useState<ZoneFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [highlightedIds, setHighlightedIds] = useState<Set<string>>(new Set())
const [isFullscreen, setIsFullscreen] = useState(false)
const [isHeatmap, setIsHeatmap] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
containerRef.current?.requestFullscreen()
} else {
document.exitFullscreen()
}
}, [])
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement)
document.addEventListener('fullscreenchange', onFsChange)
return () => document.removeEventListener('fullscreenchange', onFsChange)
}, [])
const filteredPoints = useMemo(() => {
if (!searchQuery.trim()) return points
const q = searchQuery.toLowerCase()
const matched = points.filter(
(p) => p.label.toLowerCase().includes(q) || p.path.toLowerCase().includes(q)
)
return points.map((p) => p) // keep all points but highlight matched
}, [points, searchQuery])
const searchHighlightedIds = useMemo(() => {
if (!searchQuery.trim()) return new Set<string>()
const q = searchQuery.toLowerCase()
return new Set(
points
.filter((p) => p.label.toLowerCase().includes(q) || p.path.toLowerCase().includes(q))
.map((p) => p.id)
)
}, [points, searchQuery])
const effectiveHighlightedIds = useMemo(() => {
if (highlightedIds.size > 0) return highlightedIds
return searchHighlightedIds
}, [highlightedIds, searchHighlightedIds])
const handlePointClick = (point: CosmosPoint) => {
setSelectedPoint(point)
setHighlightedIds(new Set())
}
const handleSearchChange = (query: string) => {
setSearchQuery(query)
setHighlightedIds(new Set())
if (!query.trim()) setSelectedPoint(null)
}
return (
<div
ref={containerRef}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: '#080808' }}
>
<CosmosControls
activeZone={activeZone}
searchQuery={searchQuery}
onZoneChange={setActiveZone}
onSearchChange={handleSearchChange}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
isHeatmap={isHeatmap}
onToggleHeatmap={() => setIsHeatmap((v) => !v)}
/>
<div style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden' }}>
{/* Canvas 3D — toujours monté si on a des données (caméra + état préservés au reload) */}
{!error && filteredPoints.length > 0 && (
<CosmosScene
points={filteredPoints}
activeZone={activeZone}
highlightedIds={effectiveHighlightedIds}
onPointClick={handlePointClick}
heatmap={isHeatmap}
/>
)}
{/* Loading overlay — par-dessus la scène, ne la démonte pas */}
{loading && (
<div
className="absolute inset-0 flex flex-col items-center justify-center"
style={{ background: filteredPoints.length > 0 ? 'rgba(8,8,8,0.75)' : '#080808', zIndex: 10 }}
>
<div className="text-2xl mb-3">🌌</div>
<div style={{ color: '#6366f1' }} className="text-sm font-mono">
{filteredPoints.length > 0 ? 'Mise à jour UMAP…' : 'Projection UMAP en cours…'}
</div>
{filteredPoints.length === 0 && (
<div style={{ color: '#4b5563' }} className="text-xs mt-2">
Peut prendre jusqu'à 30s lors de la première génération
</div>
)}
</div>
)}
{/* Error overlay */}
{!loading && error && (
<div
className="absolute inset-0 flex flex-col items-center justify-center"
style={{ background: '#080808' }}
>
<div style={{ color: '#ef4444' }} className="text-sm font-mono mb-2">
Erreur : {error}
</div>
<button
onClick={reload}
style={{ background: '#1a1a1a', color: '#e5e7eb', border: '1px solid #2a2a2a' }}
className="text-xs px-3 py-1.5 rounded mt-2"
>
Réessayer
</button>
</div>
)}
{/* Info panel */}
<CosmosInfoPanel
point={selectedPoint}
allPoints={points}
onClose={() => setSelectedPoint(null)}
onHighlightNeighbors={setHighlightedIds}
highlightedIds={highlightedIds}
/>
</div>
<CosmosMetrics
points={points}
generatedAt={generatedAt}
onReload={reload}
loading={loading}
/>
</div>
)
}
export default function CosmosView() {
if (!checkWebGL()) return <NoWebGL />
return <CosmosInner />
}

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import * as THREE from 'three'
import type { CosmosPoint, ZoneKey } from '../../types'
const ZONE_COLORS: Record<ZoneKey, string> = {
public: '#6366f1',
work: '#22c55e',
kernel: '#f59e0b',
unknown: '#6b7280',
}
interface Props {
points: CosmosPoint[]
}
export function CosmosBackground({ points }: Props) {
const { positions, colors } = useMemo(() => {
const positions = new Float32Array(points.length * 3)
const colors = new Float32Array(points.length * 3)
const color = new THREE.Color()
points.forEach((p, i) => {
positions[i * 3] = p.x * 3
positions[i * 3 + 1] = p.y * 3
positions[i * 3 + 2] = p.z * 3
color.set(ZONE_COLORS[p.zone] ?? ZONE_COLORS.unknown)
colors[i * 3] = color.r
colors[i * 3 + 1] = color.g
colors[i * 3 + 2] = color.b
})
return { positions, colors }
}, [points])
if (points.length === 0) return null
return (
<points>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={positions}
count={points.length}
itemSize={3}
/>
<bufferAttribute
attach="attributes-color"
array={colors}
count={points.length}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={0.04}
vertexColors
transparent
opacity={0.2}
sizeAttenuation
depthWrite={false}
/>
</points>
)
}

View File

@@ -0,0 +1,39 @@
import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import type { WorkspaceStep } from '../../types'
interface Props {
step: WorkspaceStep
onClick: () => void
}
export function GateOctahedron({ step, onClick }: Props) {
const meshRef = useRef<THREE.Mesh>(null)
const [hovered, setHovered] = useState(false)
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.8
meshRef.current.rotation.x += delta * 0.3
}
})
return (
<mesh
ref={meshRef}
position={[step.x, step.y, step.z]}
onClick={(e) => { e.stopPropagation(); onClick() }}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<octahedronGeometry args={[hovered ? 0.45 : 0.35]} />
<meshStandardMaterial
color="#f59e0b"
emissive="#f59e0b"
emissiveIntensity={hovered ? 0.6 : 0.3}
wireframe={!hovered}
/>
</mesh>
)
}

View File

@@ -0,0 +1,43 @@
import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import type { WorkspaceStep } from '../../types'
interface Props {
step: WorkspaceStep
color: string
onClick: () => void
}
export function StepSphere({ step, color, onClick }: Props) {
const meshRef = useRef<THREE.Mesh>(null)
const [hovered, setHovered] = useState(false)
useFrame(() => {
if (!meshRef.current) return
if (step.status === 'in-progress') {
meshRef.current.scale.setScalar(1 + Math.sin(Date.now() * 0.003) * 0.08)
}
})
const size = step.status === 'done' ? 0.18 : 0.25
return (
<mesh
ref={meshRef}
position={[step.x, step.y, step.z]}
onClick={(e) => { e.stopPropagation(); onClick() }}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[hovered ? size * 1.3 : size, 16, 16]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={step.status === 'in-progress' ? 0.4 : hovered ? 0.3 : 0.1}
transparent
opacity={step.status === 'done' ? 0.5 : 1}
/>
</mesh>
)
}

View File

@@ -0,0 +1,91 @@
import { Text } from '@react-three/drei'
import * as THREE from 'three'
import { StepSphere } from './StepSphere'
import { GateOctahedron } from './GateOctahedron'
import type { WorkspaceWorkflow, WorkspaceStep } from '../../types'
interface Props {
workflow: WorkspaceWorkflow
onStepClick: (step: WorkspaceStep) => void
}
const STATUS_COLORS: Record<string, string> = {
done: '#22c55e',
'in-progress': '#6366f1',
pending: '#4b5563',
gate: '#f59e0b',
fail: '#ef4444',
blocked: '#6b7280',
}
function ConnectionLine({
from,
to,
color,
animated,
}: {
from: [number, number, number]
to: [number, number, number]
color: string
animated: boolean
}) {
const points = [new THREE.Vector3(...from), new THREE.Vector3(...to)]
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const line = new THREE.Line(
geometry,
new THREE.LineBasicMaterial({ color, opacity: animated ? 1 : 0.4, transparent: true })
)
return <primitive object={line} />
}
export function WorkflowConstellation({ workflow, onStepClick }: Props) {
const firstStep = workflow.steps[0]
return (
<group>
{firstStep && (
<Text
position={[firstStep.x, firstStep.y + 1.2, firstStep.z]}
fontSize={0.25}
color={workflow.color}
anchorX="center"
anchorY="bottom"
font={undefined}
>
{workflow.name}
</Text>
)}
{workflow.steps.slice(0, -1).map((step, i) => {
const next = workflow.steps[i + 1]
return (
<ConnectionLine
key={`edge-${step.id}-${next.id}`}
from={[step.x, step.y, step.z]}
to={[next.x, next.y, next.z]}
color={STATUS_COLORS[step.status] ?? '#4b5563'}
animated={step.status === 'in-progress'}
/>
)
})}
{workflow.steps.map((step) =>
step.isGate ? (
<GateOctahedron
key={step.id}
step={step}
onClick={() => onStepClick(step)}
/>
) : (
<StepSphere
key={step.id}
step={step}
color={STATUS_COLORS[step.status] ?? '#4b5563'}
onClick={() => onStepClick(step)}
/>
)
)}
</group>
)
}

View File

@@ -0,0 +1,149 @@
import { useState } from 'react'
import type { WorkspaceStep, WorkspaceWorkflow } from '../../types'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const STATUS_COLORS: Record<string, string> = {
done: '#22c55e',
'in-progress': '#6366f1',
pending: '#4b5563',
gate: '#f59e0b',
fail: '#ef4444',
blocked: '#6b7280',
}
interface Props {
selection: { step: WorkspaceStep; wf: WorkspaceWorkflow } | null
onClose: () => void
}
export function WorkspaceInfoPanel({ selection, onClose }: Props) {
const [busy, setBusy] = useState(false)
if (!selection) return null
const { step, wf } = selection
const gateAction = async (action: 'approve' | 'abort') => {
setBusy(true)
try {
await fetch(
`${API_BASE}/gate/${encodeURIComponent(wf.id)}/${encodeURIComponent(step.id)}/approve`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
onClose()
} finally {
setBusy(false)
}
}
return (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 320,
background: '#0d0d0d',
borderLeft: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
zIndex: 10,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '1px solid #2a2a2a',
gap: 8,
}}
>
<span
style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 11, flex: 1 }}
>
{wf.name}
</span>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#6b7280',
cursor: 'pointer',
fontSize: 16,
}}
>
</button>
</div>
<div
style={{
padding: '16px',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ color: '#e5e7eb', fontWeight: 600, fontSize: 16 }}>{step.label}</div>
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
background: `${STATUS_COLORS[step.status] ?? '#4b5563'}22`,
color: STATUS_COLORS[step.status] ?? '#4b5563',
}}
>
{step.status}
</span>
{step.isGate && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
disabled={busy}
onClick={() => gateAction('approve')}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Approuver
</button>
<button
disabled={busy}
onClick={() => gateAction('abort')}
style={{
background: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Rejeter
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import type { WorkspaceWorkflow } from '../../types'
interface Props {
workflows: WorkspaceWorkflow[]
}
export function WorkspaceMetrics({ workflows }: Props) {
const total = workflows.reduce((n, wf) => n + wf.steps.length, 0)
const active = workflows.reduce(
(n, wf) => n + wf.steps.filter((s) => s.status === 'in-progress').length,
0
)
const gates = workflows.reduce(
(n, wf) => n + wf.steps.filter((s) => s.isGate && s.status === 'gate').length,
0
)
return (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
background: '#0d0d0d',
borderTop: '1px solid #2a2a2a',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: 16,
fontFamily: 'monospace',
fontSize: 11,
color: '#6b7280',
zIndex: 5,
}}
>
<span>
Workflows : <span style={{ color: '#e5e7eb' }}>{workflows.length}</span>
</span>
<span style={{ color: '#2a2a2a' }}>|</span>
<span>
Steps : <span style={{ color: '#e5e7eb' }}>{total}</span>
</span>
<span style={{ color: '#2a2a2a' }}>|</span>
<span>
Actifs : <span style={{ color: '#6366f1' }}>{active}</span>
</span>
{gates > 0 && (
<>
<span style={{ color: '#2a2a2a' }}>|</span>
<span>
Gates en attente : <span style={{ color: '#f59e0b' }}>{gates}</span>
</span>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { useState, Suspense } from 'react'
function checkWebGL(): boolean {
try {
const canvas = document.createElement('canvas')
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
} catch { return false }
}
function NoWebGL() {
return (
<div className="flex flex-col items-center justify-center h-full" style={{ background: '#080808' }}>
<div className="text-3xl mb-3">🖥</div>
<div style={{ color: '#ef4444' }} className="text-sm font-mono mb-1">WebGL non disponible</div>
<div style={{ color: '#4b5563' }} className="text-xs text-center max-w-xs">
Active l'accélération matérielle dans Chrome : Paramètres → Système → Utiliser l'accélération matérielle
</div>
</div>
)
}
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { useWorkspaceData } from '../../hooks/useWorkspaceData'
import { useCosmosData } from '../../hooks/useCosmosData'
import { WorkflowConstellation } from './WorkflowConstellation'
import { WorkspaceInfoPanel } from './WorkspaceInfoPanel'
import { WorkspaceMetrics } from './WorkspaceMetrics'
import { CosmosBackground } from './CosmosBackground'
import type { WorkspaceStep, WorkspaceWorkflow } from '../../types'
function WorkspaceInner() {
const { workflows } = useWorkspaceData()
const { points } = useCosmosData()
const [selectedStep, setSelectedStep] = useState<{
step: WorkspaceStep
wf: WorkspaceWorkflow
} | null>(null)
const [showCosmos, setShowCosmos] = useState(true)
if (workflows.length === 0) {
return (
<div
className="flex flex-col items-center justify-center h-full"
style={{ background: '#080808' }}
>
<div className="text-4xl mb-3">🌌</div>
<div style={{ color: '#4b5563' }} className="text-sm font-mono">
Aucun workflow actif
</div>
<div style={{ color: '#374151' }} className="text-xs mt-1">
Créer un workflow via K Nouveau workflow
</div>
</div>
)
}
return (
<div style={{ width: '100%', height: '100%', background: '#080808', position: 'relative' }}>
<Canvas
camera={{ position: [0, 2, 12], fov: 60 }}
gl={{ antialias: true }}
style={{ width: '100%', height: '100%' }}
>
<ambientLight intensity={0.2} />
<pointLight position={[10, 10, 10]} intensity={0.5} />
<Suspense fallback={null}>
{showCosmos && <CosmosBackground points={points} />}
{workflows.map((wf) => (
<WorkflowConstellation
key={wf.id}
workflow={wf}
onStepClick={(step) => setSelectedStep({ step, wf })}
/>
))}
</Suspense>
<OrbitControls
enableDamping
dampingFactor={0.05}
rotateSpeed={0.4}
minDistance={3}
maxDistance={30}
/>
</Canvas>
<WorkspaceInfoPanel
selection={selectedStep}
onClose={() => setSelectedStep(null)}
/>
<WorkspaceMetrics workflows={workflows} />
<button
onClick={() => setShowCosmos((v) => !v)}
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
color: showCosmos ? '#6366f1' : '#6b7280',
fontFamily: 'monospace',
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
cursor: 'pointer',
zIndex: 10,
}}
>
🌌 Cosmos
</button>
</div>
)
}
export default function WorkspaceView() {
if (!checkWebGL()) return <NoWebGL />
return <WorkspaceInner />
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from 'react'
import type { CosmosPoint, VisualizeResponse, ZoneKey } from '../types'
const CACHE_TTL_MS = 30 * 60 * 1000
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface CosmosCache {
timestamp: number
points: CosmosPoint[]
generated_at: string
umap_params: VisualizeResponse['umap_params']
}
const MOCK_ZONES: ZoneKey[] = ['public', 'kernel', 'instance', 'satellite']
function generateMockPoints(): CosmosPoint[] {
return Array.from({ length: 50 }, (_, i) => {
const zone = MOCK_ZONES[i % 4]
return {
id: `mock-${i}`,
path: `${zone}/document-${i}.md`,
zone,
label: `Document ${i}`,
excerpt: `Extrait du document ${i} — contenu de démonstration pour la visualisation Cosmos Sprint 4.`,
x: (Math.random() - 0.5) * 4,
y: (Math.random() - 0.5) * 4,
z: (Math.random() - 0.5) * 4,
}
})
}
export function useCosmosData() {
const [points, setPoints] = useState<CosmosPoint[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [generatedAt, setGeneratedAt] = useState<string | null>(null)
const [cached, setCached] = useState(false)
const cacheKey = `cosmos_cache_${Math.floor(Date.now() / CACHE_TTL_MS)}`
const load = useCallback(async (force = false) => {
setLoading(true)
setError(null)
if (USE_MOCK || !API_BASE) {
await new Promise((r) => setTimeout(r, 400))
setPoints(generateMockPoints())
setGeneratedAt(new Date().toISOString())
setCached(false)
setLoading(false)
return
}
if (!force) {
const raw = localStorage.getItem(cacheKey)
if (raw) {
try {
const parsed: CosmosCache = JSON.parse(raw)
if (Date.now() - parsed.timestamp < CACHE_TTL_MS) {
setPoints(parsed.points)
setGeneratedAt(parsed.generated_at)
setCached(true)
setLoading(false)
return
}
} catch {
localStorage.removeItem(cacheKey)
}
}
}
try {
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
const url = force ? `${API_BASE}/visualize?force=true` : `${API_BASE}/visualize`
const res = await fetch(url, { credentials: 'include', headers })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: VisualizeResponse = await res.json()
setPoints(data.points)
setGeneratedAt(data.generated_at)
setCached(data.cached)
const cachePayload: CosmosCache = {
timestamp: Date.now(),
points: data.points,
generated_at: data.generated_at,
umap_params: data.umap_params,
}
localStorage.setItem(cacheKey, JSON.stringify(cachePayload))
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue')
} finally {
setLoading(false)
}
}, [cacheKey])
useEffect(() => {
load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { points, loading, error, generatedAt, cached, reload: () => load(true) }
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react'
import type { InfraService, InfraResponse } from '../types'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const MOCK_SERVICES: InfraService[] = [
{ id: 'pm2-brain-engine', name: 'brain-engine', type: 'pm2', status: 'online', port: 7700, uptime: 3600000, restarts: 0, memory: 52428800, cpu: 0 },
{ id: 'pm2-tetardpg', name: 'tetardpg', type: 'pm2', status: 'online', port: 4000, uptime: 7200000, restarts: 2, memory: 97517568, cpu: 0 },
{ id: 'pm2-super-oauth', name: 'super-oauth', type: 'pm2', status: 'online', port: 3001, uptime: 18000000, restarts: 0, memory: 94371840, cpu: 0 },
{ id: 'pm2-originsdigital', name: 'originsdigital', type: 'pm2', status: 'online', port: 3002, uptime: 7200000, restarts: 58, memory: 83886080, cpu: 0 },
{ id: 'apache', name: 'Apache2', type: 'system', status: 'online', port: 443 },
{ id: 'brain-engine-info', name: 'brain-engine', type: 'info', status: 'online', port: 7700 },
{ id: 'gitea', name: 'Gitea', type: 'info', status: 'online', port: 3000 },
]
function formatUptime(ms: number | null | undefined): string {
if (!ms) return '—'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}j`
}
function formatMemory(bytes: number | null | undefined): string {
if (!bytes) return '—'
return `${Math.round(bytes / 1024 / 1024)}mb`
}
export function useInfra() {
const [services, setServices] = useState<InfraService[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = async () => {
setLoading(true)
setError(null)
if (USE_MOCK || !API_BASE) {
await new Promise(r => setTimeout(r, 300))
setServices(MOCK_SERVICES)
setLoading(false)
return
}
try {
const r = await fetch(`${API_BASE}/infra`, { credentials: 'include' })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data: InfraResponse = await r.json()
setServices(data.services)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur')
setServices(MOCK_SERVICES)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
return { services, loading, error, reload: load, formatUptime, formatMemory }
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react'
import { useBrainStore, LogLine } from '../store/brain.store'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const MOCK_LINES: LogLine[] = [
{ ts: new Date().toISOString(), level: 'info', msg: '[mock] workflow started' },
{ ts: new Date().toISOString(), level: 'debug', msg: '[mock] step INIT — done' },
{ ts: new Date().toISOString(), level: 'warn', msg: '[mock] gate pending — awaiting approval' },
]
export function useLogs(project: string, active: boolean) {
const logs = useBrainStore((s) => s.logs[project] ?? [])
const appendLogs = useBrainStore((s) => s.appendLogs)
const lastTsRef = useRef<string>('')
useEffect(() => {
if (!active) return
if (USE_MOCK || !API_BASE) {
appendLogs(project, MOCK_LINES)
return
}
const poll = async () => {
try {
const since = lastTsRef.current ? `?since=${encodeURIComponent(lastTsRef.current)}` : ''
const r = await fetch(`${API_BASE}/logs/${encodeURIComponent(project)}${since}`, {
credentials: 'include',
})
if (!r.ok) return
const data = await r.json()
const lines: LogLine[] = data.lines ?? []
if (lines.length > 0) {
lastTsRef.current = lines[lines.length - 1].ts
appendLogs(project, lines)
}
} catch {
// réseau — on ignore
}
}
poll()
const interval = setInterval(poll, 2000)
return () => clearInterval(interval)
}, [project, active]) // eslint-disable-line react-hooks/exhaustive-deps
return logs
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import type { TeamPreset } from '../types'
const MOCK_TEAMS: TeamPreset[] = [
{
id: 'team-frontend',
label: 'Team Frontend',
icon: '⚛️',
agents: ['brain-ui-scribe', 'frontend-stack', 'optimizer-frontend'],
capabilities: ['react', 'typescript', 'tailwind', 'vite'],
gate_required: false,
default_timeout_min: 30,
},
{
id: 'team-backend',
label: 'Team Backend',
icon: '⚙️',
agents: ['debug', 'optimizer-backend', 'optimizer-db', 'pm2', 'migration'],
capabilities: ['nestjs', 'typescript', 'mysql', 'typeorm'],
gate_required: false,
default_timeout_min: 45,
},
{
id: 'team-infra',
label: 'Team Infra',
icon: '🖥️',
agents: ['vps', 'ci-cd', 'monitoring', 'secrets-guardian'],
capabilities: ['apache', 'vps', 'ssl', 'ci-cd'],
gate_required: true,
default_timeout_min: 20,
},
{
id: 'team-content',
label: 'Team Content',
icon: '🎬',
agents: ['content-strategist', 'scriptwriter', 'seo-youtube'],
capabilities: ['youtube', 'seo', 'scriptwriting'],
gate_required: false,
default_timeout_min: 60,
},
{
id: 'team-security',
label: 'Team Sécurité',
icon: '🔒',
agents: ['security', 'secrets-guardian', 'code-review'],
capabilities: ['jwt', 'oauth', 'owasp', 'secrets-rotation'],
gate_required: true,
default_timeout_min: 30,
},
{
id: 'team-fullstack',
label: 'Team Fullstack',
icon: '🔀',
agents: ['frontend-stack', 'optimizer-backend', 'optimizer-db', 'debug'],
capabilities: ['react', 'nestjs', 'mysql', 'typescript'],
gate_required: false,
default_timeout_min: 60,
},
{
id: 'team-game',
label: 'Team Game',
icon: '🎮',
agents: ['game-designer', 'optimizer-backend', 'optimizer-db'],
capabilities: ['game-design', 'nestjs', 'mysql'],
gate_required: false,
default_timeout_min: 45,
},
]
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export function useTeams() {
const [teams, setTeams] = useState<TeamPreset[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setTeams(MOCK_TEAMS)
setIsLoading(false)
return
}
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
fetch(`${API_BASE}/teams`, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((data) => setTeams(data))
.catch(() => setTeams(MOCK_TEAMS))
.finally(() => setIsLoading(false))
}, [])
return { teams, isLoading }
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export interface TierInfo {
tier: 'owner' | 'pro' | 'free'
features: string[]
kernel_access: boolean
}
const MOCK_TIER: TierInfo = {
tier: 'owner',
features: ['cosmos', 'workspace', 'workflows', 'builder', 'secrets', 'infra', 'editor'],
kernel_access: true,
}
export function useTier() {
const [tierInfo, setTierInfo] = useState<TierInfo>(MOCK_TIER)
const [loading, setLoading] = useState(!USE_MOCK)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setTierInfo(MOCK_TIER)
setLoading(false)
return
}
fetch(`${API_BASE}/tier`, { credentials: 'include' })
.then((r) => r.json())
.then((data: TierInfo) => setTierInfo(data))
.catch(() => setTierInfo(MOCK_TIER))
.finally(() => setLoading(false))
}, [])
const hasFeature = (feature: string) => tierInfo.features.includes(feature)
return { tierInfo, loading, hasFeature }
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react'
import { useBrainStore } from '../store/brain.store'
import type { Toast } from '../components/ToastProvider'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
function buildWsUrl(): string {
// Si API_BASE est un chemin relatif (ex: '/api'), construire l'URL dynamiquement
if (!API_BASE || API_BASE.startsWith('/')) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const base = API_BASE || '/api'
return `${proto}://${location.host}${base}/ws`
}
// Si API_BASE est une URL absolue (ex: 'http://localhost:3333/api')
return API_BASE.replace(/^http/, 'ws') + '/ws'
}
const RECONNECT_DELAY_MS = 3000
type AddToast = (message: string, level: Toast['level'], context?: string) => void
export function useWebSocket(addToast?: AddToast) {
const statusRef = useRef<'connecting' | 'connected' | 'disconnected'>('disconnected')
useEffect(() => {
if (USE_MOCK || !API_BASE) {
useBrainStore.getState().setWsStatus('connected')
return
}
const wsUrl = buildWsUrl()
let ws: WebSocket | null = null
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
let destroyed = false
const setStatus = (s: 'connecting' | 'connected' | 'disconnected') => {
statusRef.current = s
const storeStatus =
s === 'connected' ? 'connected' :
s === 'connecting' ? 'disconnected' :
'disconnected'
useBrainStore.getState().setWsStatus(storeStatus)
}
const connect = () => {
if (destroyed) return
setStatus('connecting')
ws = new WebSocket(wsUrl)
ws.onopen = () => {
setStatus('connected')
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string)
const store = useBrainStore.getState()
switch (msg.type) {
case 'workflow:update':
if (Array.isArray(msg.data?.workflows)) {
store.setWorkflows(msg.data.workflows)
} else if (msg.payload) {
store.updateWorkflow(msg.payload)
}
break
case 'log:line': {
const project = msg.data?.project ?? msg.project ?? 'unknown'
const line = msg.data?.line ?? msg.line ?? ''
if (line) {
store.appendLogs(project, [{
ts: new Date().toISOString(),
level: detectLevel(line),
msg: line,
}])
}
break
}
case 'ambient:event': {
const context = msg.data?.context ?? msg.context ?? ''
const message = msg.data?.message ?? msg.message ?? ''
store.appendLogs('ambient', [{
ts: new Date().toISOString(),
level: 'info',
msg: `[${context}] ${message}`,
}])
addToast?.(
message,
(msg.data?.level ?? msg.level) === 'warn' ? 'warn' : 'info',
context || undefined,
)
break
}
case 'brain:updated': {
const path = msg.data?.path ?? msg.path ?? ''
console.log('brain:updated', path)
addToast?.(`brain mis à jour : ${path}`, 'success')
break
}
// Compatibilité avec l'ancien format gate:pending de useWorkflows
case 'gate:pending': {
const { workflowId, stepId } = msg.payload ?? {}
if (workflowId && stepId) {
store.appendLogs(workflowId, [{
ts: new Date().toISOString(),
level: 'warn',
msg: `Gate en attente — step ${stepId}`,
}])
}
const step = msg.payload?.stepId ?? msg.data?.step ?? ''
const workflow = msg.payload?.workflowId ?? msg.data?.workflow ?? ''
addToast?.(`Gate en attente : ${step}${workflow}`, 'warn')
break
}
default:
break
}
} catch {
// message malformé — ignorer
}
}
ws.onclose = () => {
if (!destroyed) {
setStatus('disconnected')
reconnectTimeout = setTimeout(connect, RECONNECT_DELAY_MS)
}
}
ws.onerror = () => {
useBrainStore.getState().setWsStatus('error')
ws?.close()
}
}
connect()
return () => {
destroyed = true
if (reconnectTimeout) clearTimeout(reconnectTimeout)
ws?.close()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { status: statusRef.current }
}
// Détecte le niveau de log d'une ligne texte brute
function detectLevel(line: string): 'info' | 'warn' | 'error' | 'debug' {
const upper = line.toUpperCase()
if (upper.includes('ERROR') || upper.includes('ERR ') || upper.includes('FATAL')) return 'error'
if (upper.includes('WARN')) return 'warn'
if (upper.includes('DEBUG')) return 'debug'
return 'info'
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { MOCK_WORKFLOWS } from '../components/WorkflowBoard'
import { useBrainStore } from '../store/brain.store'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export function useWorkflows() {
const workflows = useBrainStore((s) => s.workflows)
const wsStatus = useBrainStore((s) => s.wsStatus)
const setWorkflows = useBrainStore((s) => s.setWorkflows)
const setWsStatus = useBrainStore((s) => s.setWsStatus)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setWorkflows(MOCK_WORKFLOWS)
setWsStatus('connected')
return
}
// Fetch initial
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
fetch(`${API_BASE}/workflows`, { credentials: 'include', headers })
.then((r) => r.json())
.then((data) => setWorkflows(data))
.catch(() => setWorkflows(MOCK_WORKFLOWS))
return () => {}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { workflows, wsStatus }
}

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { useBrainStore } from '../store/brain.store'
import type { WorkspaceWorkflow } from '../types'
const WORKFLOW_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#06b6d4']
function computeLayout(workflows: ReturnType<typeof useBrainStore.getState>['workflows']): WorkspaceWorkflow[] {
return workflows.map((wf, wfIdx) => {
const baseX = (wfIdx - workflows.length / 2) * 4
const color = WORKFLOW_COLORS[wfIdx % WORKFLOW_COLORS.length]
const steps = (wf.steps ?? []).map((step, stepIdx) => {
const z = step.status === 'done' ? -stepIdx * 0.5 : stepIdx === 0 ? 1 : 0
return {
id: step.id,
label: step.label,
status: step.status as WorkspaceWorkflow['steps'][number]['status'],
isGate: step.isGate ?? false,
x: baseX + Math.sin(stepIdx * 0.8) * 0.5,
y: (workflows.length / 2 - stepIdx) * 1.5,
z,
}
})
return { id: wf.id, name: wf.name, steps, teamId: undefined, color }
})
}
export function useWorkspaceData() {
const workflows = useBrainStore((s) => s.workflows)
const workspaceWorkflows = useMemo(() => computeLayout(workflows), [workflows])
return { workflows: workspaceWorkflows }
}

45
brain-ui/src/index.css Normal file
View File

@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body {
margin: 0;
background: #0d0d0d;
color: #e5e7eb;
font-family: 'Inter', system-ui, sans-serif;
}
/* React Flow overrides */
.react-flow__background { background: #0d0d0d; }
.react-flow__edge-path { stroke: #2a2a2a; }
/* Docs markdown */
.docs-markdown { max-width: 780px; line-height: 1.7; color: #d1d5db; }
.docs-markdown h1 { font-size: 1.75rem; font-weight: 700; color: #f3f4f6; margin: 0 0 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid #2a2a2a; }
.docs-markdown h2 { font-size: 1.25rem; font-weight: 600; color: #e5e7eb; margin: 2rem 0 0.75rem; padding-bottom: 0.25rem; border-bottom: 1px solid #1f1f1f; }
.docs-markdown h3 { font-size: 1.05rem; font-weight: 600; color: #c4c8ce; margin: 1.5rem 0 0.5rem; }
.docs-markdown p { margin: 0.5rem 0; }
.docs-markdown strong { color: #f3f4f6; }
.docs-markdown code { background: #1e1e1e; color: #a78bfa; padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.875em; }
.docs-markdown pre { background: #141414; border: 1px solid #2a2a2a; border-radius: 6px; padding: 1rem; overflow-x: auto; margin: 0.75rem 0; }
.docs-markdown pre code { background: none; padding: 0; color: #d1d5db; }
.docs-markdown table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; font-size: 0.875rem; }
.docs-markdown th { text-align: left; padding: 0.5rem 0.75rem; background: #1a1a1a; color: #9ca3af; border-bottom: 1px solid #2a2a2a; font-weight: 600; }
.docs-markdown td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #1a1a1a; }
.docs-markdown tr:hover td { background: rgba(99,102,241,0.05); }
.docs-markdown ul, .docs-markdown ol { margin: 0.5rem 0; padding-left: 1.5rem; }
.docs-markdown li { margin: 0.25rem 0; }
.docs-markdown hr { border: none; border-top: 1px solid #2a2a2a; margin: 1.5rem 0; }
.docs-markdown blockquote { border-left: 3px solid #6366f1; padding: 0.5rem 1rem; margin: 0.75rem 0; color: #9ca3af; background: rgba(99,102,241,0.05); border-radius: 0 4px 4px 0; }
.docs-markdown blockquote.tier-free { border-left-color: #22c55e; background: rgba(34,197,94,0.06); }
.docs-markdown blockquote.tier-free strong { color: #4ade80; }
.docs-markdown blockquote.tier-featured { border-left-color: #3b82f6; background: rgba(59,130,246,0.06); }
.docs-markdown blockquote.tier-featured strong { color: #60a5fa; }
.docs-markdown blockquote.tier-pro { border-left-color: #f59e0b; background: rgba(245,158,11,0.06); }
.docs-markdown blockquote.tier-pro strong { color: #fbbf24; }
.docs-markdown blockquote.tier-full { border-left-color: #a855f7; background: rgba(168,85,247,0.06); }
.docs-markdown blockquote.tier-full strong { color: #c084fc; }
.docs-markdown a { color: #818cf8; text-decoration: none; }
.docs-markdown a:hover { text-decoration: underline; }

10
brain-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,36 @@
import { create } from 'zustand'
import type { Workflow } from '../types'
export interface LogLine {
ts: string
level: 'info' | 'warn' | 'error' | 'debug'
msg: string
}
interface BrainStore {
workflows: Workflow[]
logs: Record<string, LogLine[]>
wsStatus: 'connected' | 'disconnected' | 'error'
setWorkflows: (w: Workflow[]) => void
updateWorkflow: (w: Workflow) => void
appendLogs: (project: string, lines: LogLine[]) => void
clearLogs: (project: string) => void
setWsStatus: (s: BrainStore['wsStatus']) => void
}
export const useBrainStore = create<BrainStore>((set) => ({
workflows: [],
logs: {},
wsStatus: 'disconnected',
setWorkflows: (workflows) => set({ workflows }),
updateWorkflow: (w) => set((s) => ({
workflows: s.workflows.map((x) => (x.id === w.id ? w : x)),
})),
appendLogs: (project, lines) => set((s) => ({
logs: { ...s.logs, [project]: [...(s.logs[project] ?? []), ...lines] },
})),
clearLogs: (project) => set((s) => ({
logs: { ...s.logs, [project]: [] },
})),
setWsStatus: (wsStatus) => set({ wsStatus }),
}))

105
brain-ui/src/types/index.ts Normal file
View File

@@ -0,0 +1,105 @@
export type StepStatus = 'pending' | 'in-progress' | 'done' | 'gate' | 'partial' | 'fail' | 'blocked'
export interface WorkflowStep {
id: string
label: string
status: StepStatus
isGate?: boolean
}
export interface Workflow {
id: string
name: string
project: string
steps: WorkflowStep[]
}
// Team presets
export interface TeamPreset {
id: string
label: string
icon: string
agents: string[]
capabilities: string[]
gate_required: boolean
default_timeout_min: number
}
// WorkflowBuilder
export type StepDraftType = 'step' | 'gate'
export interface StepDraft {
id: string
label: string
type: StepDraftType
agentHint?: string
}
export interface WorkflowDraft {
title: string
teamId: string
steps: StepDraft[]
gateRequired: boolean
}
// Cosmos — Sprint 4
export type ZoneKey = 'public' | 'work' | 'kernel' | 'instance' | 'satellite' | 'unknown'
export interface CosmosPoint {
id: string
path: string
zone: ZoneKey
label: string
excerpt: string
x: number
y: number
z: number
}
export interface VisualizeResponse {
points: CosmosPoint[]
generated_at: string
cached: boolean
umap_params: {
n_components: 3
n_neighbors: number
min_dist: number
}
}
// Workspace — Sprint 5
export interface WorkspaceStep {
id: string
label: string
status: 'pending' | 'in-progress' | 'done' | 'gate' | 'fail' | 'blocked'
isGate?: boolean
x: number
y: number
z: number
}
export interface WorkspaceWorkflow {
id: string
name: string
steps: WorkspaceStep[]
teamId?: string
color: string
}
// InfraRegistry — Sprint 7
export interface InfraService {
id: string
name: string
type: 'pm2' | 'system' | 'info'
status: 'online' | 'stopped' | 'errored' | 'unknown'
port?: number | null
uptime?: number | null
restarts?: number
memory?: number
cpu?: number
}
export interface InfraResponse {
services: InfraService[]
total: number
}

11
brain-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_USE_MOCK: string
readonly VITE_BRAIN_API: string
readonly VITE_BRAIN_TOKEN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,20 @@
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brain: {
bg: '#0d0d0d',
surface: '#1a1a1a',
border: '#2a2a2a',
accent: '#6366f1',
gate: '#f59e0b',
ok: '#22c55e',
fail: '#ef4444',
muted: '#6b7280',
}
}
}
},
plugins: []
}

15
brain-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

19
brain-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/ui/',
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:7700',
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
})

37
docs/README.md Normal file
View File

@@ -0,0 +1,37 @@
# docs/ — Documentation humaine
> Guides lisibles sans contexte brain. Pour forks, onboarding, ou quand tu veux comprendre comment ca marche.
---
## Guides disponibles
| Guide | Description |
|-------|-------------|
| **Guides** | |
| [getting-started.md](getting-started.md) | **Commence ici** — les 5 premieres minutes apres un fork |
| [architecture.md](architecture.md) | Comment les pieces s'assemblent — version humaine |
| [sessions.md](sessions.md) | Types de sessions, permissions, metabolisme, close sequences |
| [workflows.md](workflows.md) | Recettes d'agents — quels agents combiner pour quoi |
| **Agents** | |
| [agents.md](agents.md) | Vue d'ensemble — comparatif tiers, navigation |
| [agents-code.md](agents-code.md) | Agents code & qualite — review, securite, tests, refacto, perf |
| [agents-infra.md](agents-infra.md) | Agents infra & deploy — VPS, CI/CD, monitoring, mail |
| [agents-brain.md](agents-brain.md) | Agents brain & systeme — coach, scribes, orchestration, kernel |
| **Vues par tier** | |
| [vue-tiers.md](vue-tiers.md) | Comparatif — tous les tiers d'un coup d'oeil |
| [vue-free.md](vue-free.md) | 🟢 free — ce que tu as |
| [vue-featured.md](vue-featured.md) | 🔵 featured — ce que tu gagnes |
| [vue-pro.md](vue-pro.md) | 🟠 pro — l'atelier complet |
| [vue-full.md](vue-full.md) | 🟣 full — ton brain, tes regles |
---
## Difference avec wiki/
| Espace | Audience | Contenu |
|--------|----------|---------|
| `docs/` | Humains, forks, onboarding | Guides, explications, FAQ |
| `wiki/` | Agents IA, brain interne | Matrices, specs techniques, lifecycle |
Les deux espaces documentent le meme systeme — `docs/` en langage humain, `wiki/` en reference technique.

170
docs/agents-brain.md Normal file
View File

@@ -0,0 +1,170 @@
# Agents Brain & Systeme
> Les agents qui font vivre le brain — documentation, coaching, orchestration, protection.
---
## Coach — ta progression
### coach
> 🟢 **free** : `coach-boot` (observation legere)
> 🔵 **featured+** : `coach` complet (mentorat, bilans, objectifs)
Le coach est toujours present. Ce qui change selon ton tier :
> 🟢 **free** — Observe en silence. Intervient uniquement sur un risque critique.
> 🔵 **featured** — Bilans de session, objectifs SMART, progression tracee.
> 🟠 **pro** — Idem + contexte projet (review code, patterns, architecture).
> 🟣 **full** — Mentorat long terme — anticipe, challenge les decisions, milestones.
Le coach adapte aussi son comportement au type de session :
- **Silencieux** (navigate, deploy, infra, urgence, audit) — pas de rapport, risque critique uniquement
- **Standard** (work, debug) — actif sur les patterns d'erreur
- **Engage** (brain, brainstorm) — challenge les decisions
- **Complet** (coach, capital) — mentorat structure
- **Copilote** (pilote) — proactif, anticipe
---
## Scribes — la memoire
Les scribes ecrivent pour que rien ne se perde. Chacun a son territoire :
### scribe
> 🟢 **free**
Gardien principal du brain. Met a jour `focus.md`, les fiches projets, l'index des agents. Detecte ce qui est obsolete et le signale.
S'active en fin de session significative (commits, agents forges, decisions prises).
---
### todo-scribe
> 🟢 **free**
Ecrit dans `brain/todo/`. Capture les intentions non realisees, les taches a planifier. Ne priorise pas — il structure et persiste.
---
### metabolism-scribe
> 🟢 **free**
Mesure la sante de chaque session : tokens, duree, commits, context peak. Calcule le `health_score` et le ratio use-brain/build-brain sur 7 jours.
Il ne juge pas — il mesure. Les tendances parlent d'elles-memes.
---
### wiki-scribe
> 🟢 **free**
Maintient la documentation du brain sur deux territoires :
- `wiki/` — reference technique (agents, matrices, specs)
- `docs/` — guides humains (ce que tu lis maintenant)
Route automatiquement : "lisible sans contexte brain ?" → docs, sinon → wiki.
---
### coach-scribe
> 🔵 **featured**
Persiste la progression dans `progression/` : journal de session, competences, milestones. Separe du coach — le coach observe, le scribe ecrit.
---
### toolkit-scribe
> 🟠 **pro**
Capture les patterns valides en prod dans `toolkit/`. Chaque pattern reussi en session devient un template reutilisable.
---
## Orchestration — le systeme nerveux
### helloWorld
> 🟢 **free**
Le majordome. Premier agent au reveil : lit l'etat du systeme, produit le briefing, ouvre le claim BSI, passe la main a session-orchestrator.
---
### session-orchestrator
> 🟢 **free**
Proprietaire du cycle de vie. Decide ce qui est charge au boot, route le travail, declenche les scribes a la fermeture. Ne produit rien — il orchestre.
---
### secrets-guardian
> 🟢 **free**
Surveille les secrets en permanence. Silencieux quand tout va bien — fracassant des qu'une fuite est detectee. Session suspendue, zero exception.
4 surfaces surveillees : code source, chat, commandes shell, outputs d'outils.
---
### brain-guardian
> 🟢 **free**
Auto-mefiance structurelle. Quand le brain travaille sur lui-meme, cet agent exige des preuves pour chaque assertion. Empeche le brain de se convaincre qu'il fonctionne bien sans verification.
---
## Agents systeme — le boot de tous les tiers
> 🟢 **free** — ces agents tournent a chaque boot, quel que soit le tier. Ce sont eux qui font fonctionner le systeme de tiers.
### key-guardian
Valide la Brain API Key au boot. Pas de cle → tier free (silencieux, pas d'erreur). Cle valide → ecrit le tier dans la config. Cache le resultat 24h. VPS down → grace period 72h.
### pre-flight
Gate de boot — verifie que le tier actif autorise la session demandee, que le kerneluser est correct, et que le write_lock est respecte. Bloque si les conditions ne sont pas remplies.
### feature-gate
Feature flags runtime — verifie que chaque agent et session respecte le tier actif. Enforcement silencieux : un agent hors tier n'est pas charge, sans erreur.
---
## Agents kernel — supervision avancee
> 🟣 **full** — supervision pour l'owner du brain
### brain-hypervisor
Supervise les sequences multi-phase. Detecte le drift (quand un workflow derive de son objectif) et intervient.
### kernel-orchestrator
Execute les workflows BSI. Circuit breaker a 3 echecs consecutifs — arret complet, signal humain obligatoire.
---
## Tous les agents de cette page
> 🟢 **free** — `coach-boot` · `scribe` · `todo-scribe` · `metabolism-scribe` · `wiki-scribe` · `helloWorld` · `session-orchestrator` · `secrets-guardian` · `brain-guardian` · `key-guardian` · `pre-flight` · `feature-gate`
> 🔵 **featured** — `coach` (complet) · `coach-scribe`
> 🟠 **pro** — `toolkit-scribe`
> 🟣 **full** — `brain-hypervisor` · `kernel-orchestrator`

112
docs/agents-code.md Normal file
View File

@@ -0,0 +1,112 @@
# Agents Code & Qualite
> Les specialistes qui analysent, reviewent, testent et optimisent ton code.
---
## Review & Securite
### code-review
> 🟠 **pro**
Analyse tout code soumis selon 7 priorites, de la plus critique a la moins urgente :
1. **Securite** — injections, secrets exposes, tokens mal geres
2. **Edge cases** — entrees inattendues, etats limites
3. **Performance** — boucles inutiles, N+1, fuites memoire
4. **Async & erreurs** — promesses, try/catch, rejets non geres
5. **Typage** — pas de `any` sauvage
6. **Clean code** — lisible, maintenable
7. **Obsolescence** — patterns deprecies
Format adaptatif : inline sur un snippet court, rapport structure sur un fichier long.
Si un finding est critique → delegue a `security`. Apres review → suggere `testing`.
---
### security
> 🟠 **pro**
Audite la securite applicative selon 8 priorites :
1. Secrets exposes
2. Auth & tokens (JWT, OAuth2, refresh)
3. Injections (SQL, shell)
4. CSRF / CORS
5. XSS
6. Rate limiting
7. Headers securite
8. Exposition de donnees
Couvre la couche applicative. Pour la couche infra (Apache, SSL, ports) → delegue a `vps`.
---
## Tests
### testing
> 🟠 **pro**
Ecrit les tests et definit la strategie de coverage. Adaptatif :
- **Nouveau code** → TDD : tests d'abord, implementation ensuite
- **Code existant non couvert** → Retroactif : tests sur le comportement constate
- **Refacto prevue** → TDD : les tests guident la refacto
Strategie par couche : tests unitaires purs sur le domaine, mocks sur l'application, integration vraie sur l'infra et les routes.
---
### refacto
> 🟠 **pro**
Restructure le code sans perdre une seule ligne de logique metier. Methode en 5 etapes :
```
1. DIAGNOSTIC — identifier le probleme
2. PLAN — lister les etapes (moins risquee → plus risquee)
3. VALIDATION — confirmer avec toi avant d'agir
4. EXECUTION — une etape a la fois, tests verts a chaque fois
5. VERIFICATION — comportement identique avant/apres
```
3 niveaux de risque : code local (faible) → module (moyen) → architecture (eleve).
Pas de tests existants ? → `testing` les ecrit avant la refacto.
---
## Performance — le trio
> 🟠 **pro** — les 3 agents travaillent en trio ou separement
### optimizer-backend
Perf Node.js — detecte les `await` dans les `forEach`, les fuites memoire, les boucles qui bloquent l'event loop. Suggere `Promise.all`, streams, workers.
### optimizer-db
Perf MySQL — detecte les N+1 (TypeORM), les index manquants, les requetes lentes. Utilise `EXPLAIN` et `slow_query_log`.
### optimizer-frontend
Perf React — detecte les re-renders inutiles, les imports lourds, le lazy loading manquant. Utilise React DevTools Profiler et bundle analyzer.
**Invoquer les 3** pour un audit perf full-stack.
---
## Qui delegue a qui
- `code-review``security` (faille trouvee) · `testing` (couvrir le fix) · `refacto` (structure)
- `security``vps` (infra) · `ci-cd` (secrets pipeline)
- `testing``security` (tests auth) · `code-review` (review des tests)
- `refacto``testing` (tests avant refacto) · `debug` (bugs trouves)
- `optimizer-backend``optimizer-db` (requetes) · `code-review` (qualite)
- `optimizer-db``optimizer-backend` (applicatif) · `vps` (config serveur)
- `optimizer-frontend``ci-cd` (config build)

87
docs/agents-infra.md Normal file
View File

@@ -0,0 +1,87 @@
# Agents Infra & Deploy
> Les specialistes qui deploient, surveillent et maintiennent ton infrastructure.
---
## Deploy & Serveur
### vps
> 🟠 **pro**
Expert de ton VPS — deploie un nouveau service de A a Z :
```
1. Copier le template vhost Apache
2. Activer les modules (proxy, rewrite, headers)
3. Activer le vhost + configtest
4. Pointer le DNS
5. Generer le certificat SSL (Let's Encrypt)
```
Regle non negociable : `apache2ctl configtest` avant chaque reload — un typo = tous les services tombent.
Agit seul sur les actions non destructives. Demande confirmation avant de supprimer un vhost, modifier un container en prod, ou ouvrir un port.
---
### ci-cd
> 🟠 **pro**
Concoit et debug les pipelines CI/CD. Adaptatif par projet :
- **Site statique** → `git pull` uniquement
- **Node.js sans Docker** → `git pull` + `npm ci` + `npm run build`
- **Node.js avec Docker** → `git pull` + `docker compose up -d --build`
- **Config Apache changee** → + `apache2ctl configtest && systemctl reload`
Plateforme : GitHub Actions pour les projets publics, Gitea CI pour le prive.
---
### pm2
> 🟠 **pro**
Process manager Node.js en prod. Gere le cycle de vie des applications (start, restart, logs, monitoring). Intervient quand un process tombe ou consomme trop.
---
### migration
> 🟠 **pro**
Gere les migrations TypeORM — creation, modification, deploiement safe. Verifie que les migrations passent sans perte de donnees et que le rollback est possible.
---
## Surveillance
### monitoring
> 🟠 **pro**
Observabilite — configure les sondes Uptime Kuma, lit les logs VPS, detecte les anomalies. Suggere une sonde apres chaque nouveau deploiement.
---
### mail
> 🟠 **pro**
Specialiste Stalwart (serveur mail). Gere la config SMTP/IMAP, les enregistrements DNS (SPF, DKIM, DMARC), et le diagnostic des problemes de delivrabilite.
VPS gere le serveur, mail gere le protocole.
---
## Qui delegue a qui
- `vps``mail` (Stalwart) · `ci-cd` (pipeline)
- `ci-cd``vps` (config serveur) · `monitoring` (sonde post-deploy)
- `pm2``vps` (si probleme container)
- `migration``debug` (si migration echoue)
- `monitoring``vps` (diagnostic infra)
- `mail``vps` (serveur) · `security` (SPF/DKIM)

91
docs/agents.md Normal file
View File

@@ -0,0 +1,91 @@
# Le brain en 30 secondes
Un brain, c'est un systeme de **specialistes IA** qui travaillent ensemble. Chaque specialiste (agent) fait une chose bien : debugger, reviewer du code, deployer, ecrire des tests. Tu n'en charges jamais plus de 5 a la fois — le brain sait lesquels activer selon ce que tu fais.
Tu forkes le brain, tu codes. Les agents se chargent automatiquement.
---
## Les 4 tiers
> 🟢 **free — Tu forkes, ca marche**
>
> **14 agents + 8 systeme. 6 sessions.** Pas de cle API, pas de config.
>
> Debug, brainstorm, scribes automatiques, protection secrets, creation d'agents custom. Le coach observe en arriere-plan.
> 🔵 **featured — Le brain te connait**
>
> **18 agents + systeme. 8 sessions.** Le coach se reveille.
>
> Bilans de session, objectifs concrets, progression tracee. Le brain se souvient de tes acquis entre sessions grace a la distillation RAG.
> 🟠 **pro — L'atelier complet**
>
> **40 agents + systeme. 12 sessions.** Tu ship en prod.
>
> Code review (7 priorites), audit securite (8 priorites OWASP), tests automatises, 3 optimiseurs perf, deploy VPS + CI/CD + SSL, sessions urgence et infra.
> 🟣 **full — Ton brain, tes regles**
>
> **75 agents (tous). 15 sessions.** Tu es owner.
>
> Modification du kernel, copilotage long (mode pilote), supervision multi-phase (hypervisor), coach proactif qui anticipe.
---
## Ce qui change quand tu montes
> 🟢 → 🔵 **free vers featured**
>
> Le coach passe de spectateur a mentor. Il fait un bilan a chaque session, fixe des objectifs, et trace ta progression. Le brain apprend de toi — il se souvient entre sessions.
> 🔵 → 🟠 **featured vers pro**
>
> Tu recois une equipe complete : review code, audit securite, tests, refacto, 3 optimiseurs perf, deploy prod, monitoring, pipelines CI/CD. Plus besoin d'improviser — le brain fait le travail metier.
> 🟠 → 🟣 **pro vers full**
>
> Tu deviens owner. Tu modifies le brain lui-meme (kernel, agents, profil). Sessions longues en copilote proactif. Supervision multi-phase avec circuit breaker.
---
## Comment ca marche en pratique
**Les agents se chargent tout seuls.** Tu parles de "bug" → `debug` arrive. Tu dis "deploy" → `vps` + `ci-cd` se chargent. Tu peux aussi les appeler :
```
Charge l'agent testing
Charge les agents security et code-review
```
**Ils se delegent entre eux.** Chaque agent connait ses limites :
- `debug` detecte un probleme infra → passe a `vps`
- `code-review` trouve une faille → passe a `security`
- `optimizer-db` voit un probleme Node.js → passe a `optimizer-backend`
**Ils ne chargent que l'essentiel.** Un agent de 200 lignes → ~25 lignes au boot. Le reste se charge quand tu en as besoin.
---
## Explore les agents par famille
**Code & Qualite** — review, securite, tests, refacto, 3 optimiseurs perf
**Infra & Deploy** — VPS, pipelines CI/CD, monitoring, process manager, mail
**Brain & Systeme** — coach, scribes, orchestration, protection, kernel
→ Chaque famille est accessible dans la sidebar.
---
## Nouveautes
| Date | Quoi de neuf |
|------|-------------|
| 2026-03-20 | Agents 87% plus legers au boot |
| 2026-03-20 | Coach adaptatif — 5 comportements selon la session |
| 2026-03-20 | Fermeture fiable — sequence deterministe |
| 2026-03-18 | Auto-mefiance — le brain se verifie quand il s'edite |
| 2026-03-17 | Supervision avancee — hypervisor + circuit breaker |

165
docs/architecture.md Normal file
View File

@@ -0,0 +1,165 @@
# Architecture du brain
> Comment les pieces s'assemblent. Version humaine — pas la spec technique.
---
## Vue d'ensemble
Le brain c'est 3 couches :
**1. Le kernel** — l'identite
- Les regles qui ne changent pas (KERNEL.md, constitution, PATHS.md)
- Les agents specialises (~75 fichiers .md)
- Le profil de collaboration
- Le brain-compose.yml (config, tiers, modes)
**2. Les satellites** — la memoire
- `todo/` — les intentions et taches
- `progression/` — ta progression, tes skills, ton metabolisme
- `toolkit/` — les patterns valides en prod, reutilisables
- `reviews/` — les audits d'agents
- `profil/` — ton identite, tes objectifs
Chaque satellite est un repo Git independant. Le kernel les ignore (gitignore). Ils vivent leur vie.
**3. L'instance** — le runtime
- `claims/` — quelle session est active, sur quoi
- `workspace/` — les sprints en cours, checkpoints
- `brain-compose.local.yml` — config machine (tier, cle API, peers)
- `brain.db` — base SQLite pour BSI et etat live
---
## Comment une session fonctionne
```
Tu tapes "brain boot mode work/mon-projet"
|
v
helloWorld lit ta config
|
v
Charge le minimum (L0 : kernel + paths + config)
|
v
Lit le manifest de session (contexts/session-work.yml)
|
v
Charge les agents pertinents (L1 : debug, coach-boot, scribe)
|
v
Charge le projet si declare (L2 : projets/mon-projet.md + todo/mon-projet.md)
|
v
Ouvre un claim BSI (trace de session)
|
v
"Pret." → tu travailles
|
v
Tu dis "on wrappe"
|
v
Close sequence : metriques → todos → scribe → coach → BSI close
```
---
## Les 4 couches de chargement
Le brain ne charge pas tout. Il utilise 4 couches, du plus leger au plus complet :
**L0 — Toujours charge** (~5%)
3 fichiers. L'identite du brain. Jamais retire.
**L1 — Selon la session** (~15%)
Les agents et fichiers specifiques au type de session. `work` charge debug + coach. `deploy` charge vps + ci-cd. Deterministe : meme session = meme chargement.
**L2 — Selon le projet** (~10%)
Si tu declares un projet dans ta commande, ses fichiers sont charges. Silencieux si le projet n'existe pas.
**L3 — Sur demande** (0% au boot)
Tout le reste. Tu demandes "Charge l'agent testing" → il arrive. Jamais proactif.
**Resultat** : ~25% du contexte au boot, pas 80%. Le brain demarre vite.
---
## Les zones — qui ecrit ou
Le brain a des zones protegees. Chaque session sait ou elle peut ecrire :
**Zone kernel** — protection maximale
KERNEL.md, CLAUDE.md, agents/, profil/. Aucune modification sans decision humaine. Session `edit-brain` requise.
**Zone satellites** — vie libre
todo/, toolkit/, progression/, reviews/. Les scribes ecrivent librement. Promotion vers le kernel possible.
**Zone instance** — etat runtime
claims/, workspace/, brain.db. Geree automatiquement par les agents systeme.
**Zone projet** — code externe
Ton code, tes repos. Le brain y travaille en session `work`/`debug`/`deploy` mais ne melange jamais avec le kernel.
> Regle : une feature grandit dans un satellite → elle peut etre promue dans le kernel. Le kernel ne derive jamais vers un satellite.
---
## Les tiers — qui a acces a quoi
Le brain a 4 niveaux d'acces. Chaque tier debloque des agents et des sessions :
> 🟢 **free** — le brain fonctionne. Debug, brainstorm, scribes, protection secrets.
> 🔵 **featured** — le brain te connait. Coach complet, distillation RAG, progression.
> 🟠 **pro** — l'atelier complet. Review, securite, tests, deploy, perf, infra.
> 🟣 **full** — ton brain. Modification kernel, pilotage long, supervision.
Detail complet → voir les Vues par tier dans la sidebar.
---
## Les agents — comment ils s'organisent
Chaque agent a un fichier `.md` avec :
- Un **boot-summary** (~25 lignes) — charge au demarrage de session
- Un **detail** (reste du fichier) — charge quand l'agent est actif
Les agents se declenchent automatiquement (domaine detecte) ou sur invocation explicite. Ils se delegent entre eux — chaque agent connait ses limites.
**4 familles :**
- **Metier** — debug, review, securite, tests, refacto, perf, infra
- **Scribes** — scribe, todo-scribe, metabolism-scribe, wiki-scribe
- **Presences** — coach, secrets-guardian, helloWorld, session-orchestrator
- **Systeme** — key-guardian, pre-flight, feature-gate, hypervisor
---
## Multi-instance
Le brain peut tourner sur plusieurs machines et plusieurs sessions en parallele.
- Chaque session a un **claim BSI** — les sessions se voient entre elles
- Les **peers** se declarent dans brain-compose.local.yml
- La **synchronisation** passe par Git (push/pull) et brain.db (SQLite replique)
Si deux sessions veulent ecrire au meme endroit → conflit detecte, resolution humaine.
---
## Pour aller plus loin
- **Detail technique** : wiki/ — session-matrix, context-loading, agents-architecture
- **Agents par famille** : Code & Qualite, Infra & Deploy, Brain & Systeme dans la sidebar
- **Recettes** : Workflows dans la sidebar

135
docs/getting-started.md Normal file
View File

@@ -0,0 +1,135 @@
# Demarrer avec le brain
> Tu viens de forker. Voici tes 5 premieres minutes.
---
## Etape 1 — Installer
```bash
git clone <ton-fork> ~/Dev/Brain
cd ~/Dev/Brain
```
Si c'est une nouvelle machine, lance le setup complet :
```bash
bash scripts/brain-setup.sh prod ~/Dev/Brain
```
Ca clone les satellites (toolkit, progression, todo, reviews, profil), installe les hooks, et prepare CLAUDE.md.
---
## Etape 2 — Premier boot
Ouvre Claude Code dans le dossier du brain et tape :
```
brain boot
```
C'est tout. Le brain :
1. Lit ta config machine
2. Charge le minimum necessaire (~20% du contexte)
3. Te presente un briefing : etat du systeme, projets actifs, todos
4. Ouvre un claim BSI (trace de session)
5. Te demande ce que tu veux faire
---
## Etape 3 — Travailler
**Tu veux coder sur un projet :**
```
brain boot mode work/mon-projet
```
Le brain charge les agents pertinents (debug, scribe, todo-scribe) et le fichier projet si il existe.
**Tu veux explorer ou reflechir :**
```
brain boot mode brainstorm/sujet
```
Mode libre, pas de livrable attendu. L'agent `brainstorm` challenge tes idees.
**Tu ne sais pas quoi faire :**
```
brain boot
```
Le briefing te montre tes todos, tes projets actifs, et te pose la question. Reponds naturellement — le brain detecte le type de session.
---
## Etape 4 — Fermer proprement
Quand tu as fini :
```
on wrappe
```
Le brain lance la sequence de fermeture :
- Capture les metriques de ta session
- Met a jour tes todos
- Ferme le claim BSI
Ne ferme pas le terminal avant que le claim soit ferme.
---
## Les commandes essentielles
**Boot**
- `brain boot` — demarrage standard
- `brain boot mode <type>` — choisir son mode (work, debug, brainstorm, brain...)
- `brain boot navigate` — mode lecture seule, le plus leger
**En session**
- `Charge l'agent <nom>` — invoquer un agent specifique
- `/btw <question>` — parenthese rapide sans casser le fil
- `checkpoint` — sauvegarder l'etat avant une pause
**Fermeture**
- `on wrappe` ou `fin` — fermeture propre avec metriques
---
## Les 3 choses a savoir
**1. Le brain charge le minimum.** Il ne lit pas tout au demarrage. Il charge ~20-30% du contexte selon ta session et ajoute le reste a la demande. C'est pour ca qu'il demarre vite.
**2. Les agents se chargent tout seuls.** Tu parles de "bug" → l'agent `debug` arrive. Tu dis "deploy" → `vps` + `ci-cd` se chargent. Tu n'as pas besoin de tout connaitre — le brain route.
**3. Les secrets ne passent jamais dans le chat.** Le `secrets-guardian` surveille en permanence. Si un secret apparait accidentellement, la session se suspend. C'est normal — c'est une protection.
---
## Bonus — le dashboard
Le brain a un dashboard web avec tes docs, tes workflows, et une visualisation 3D de ton corpus.
```bash
# Build le dashboard (une seule fois)
bash brain-ui/build.sh
# Lance brain-engine (sert aussi le dashboard)
bash brain-engine/start.sh
# Ouvre dans ton navigateur
# http://localhost:7700/ui/
```
---
## Et apres ?
- **Voir ce que tu as** → Vue d'ensemble (Agents & Tiers) dans la sidebar
- **Comprendre les sessions** → Sessions dans la sidebar
- **Voir les recettes d'agents** → Workflows dans la sidebar
- **Comprendre l'architecture** → Architecture dans la sidebar

224
docs/sessions.md Normal file
View File

@@ -0,0 +1,224 @@
# Guide des sessions — Brain
> Ce guide explique comment fonctionnent les sessions du brain.
> Pour la reference technique complete : `wiki/session-matrix.md`
---
## C'est quoi une session ?
Une session est une conversation avec le brain, du premier message au dernier commit. Chaque session a un **type** qui determine quels agents sont charges, quels fichiers sont accessibles, et ce que le brain peut ecrire.
Le cycle de vie est simple : **boot → work → close**.
- **Boot** : le brain detecte le type de session, charge le contexte minimum necessaire
- **Work** : tu travailles, les agents pertinents sont disponibles
- **Close** : les scribes capturent les metriques, mettent a jour les todos, et ferment le claim BSI
**Le brain demarre toujours en session.** Si tu ne declares pas de type, tu es automatiquement en session `navigate` — la plus legere. C'est le lobby : tu peux regarder autour, poser des questions, et quand tu veux travailler, tu escalades vers le bon type.
---
## Isolation et escalade
Chaque type de session a un perimetre strict. Le brain ne deborde jamais :
- En **navigate** : lecture seule, orientation — pas de code, pas de modification brain
- En **work** : code projet — pas de modification du brain (kernel, agents)
- En **brain** : modification du brain — pas de code projet
- En **edit-brain** : modification kernel — gate humain obligatoire
Si tu demandes quelque chose qui depasse le scope de ta session, le brain te propose d'escalader :
```
"Cette action depasse le scope navigate — brain boot mode work/superoauth pour continuer."
```
Tu confirmes, le brain ferme la session legere et ouvre la bonne. Deux claims dans l'historique, tout est trace.
---
## Les types de sessions
**Coder & produire**
- `work` — Developpement projet → `brain boot mode work/<projet>`
- `debug` — Investigation bug → `brain boot mode debug/<projet>`
- `deploy` — Ship en prod, config VPS → `brain boot mode deploy/<projet>`
- `infra` — Maintenance VPS, monitoring → `brain boot mode infra`
- `urgence` — Production down, hotfix → `brain boot mode urgence`
**Construire le brain**
- `brain` — Travailler sur les agents, todos, focus → `brain boot mode brain`
- `edit-brain` — Modifier le kernel (gate humain) → `brain boot sudo`
- `kernel` — Lire le kernel sans le modifier → `brain boot mode kernel`
- `pilote` — Session longue, copilotage actif → `brain boot mode pilote`
**Explorer & reflechir**
- `brainstorm` — Explorer, challenger, structurer → `brain boot mode brainstorm/<sujet>`
- `navigate` — Vue d'ensemble legere → `brain boot navigate`
- `coach` — Progression, reflexion strategique → `brain boot mode coach`
- `capital` — Bilan, objectifs, CV → `brain boot mode capital`
- `audit` — Analyse lecture seule, rapport → `brain boot mode audit/<projet>`
- `handoff` — Reprendre une session precedente → `brain boot mode handoff/<id>`
---
## Comment ca se lance — les 4 couches
Le brain ne charge pas tout d'un coup. Il utilise 4 couches, comme des pelures d'oignon :
```
L0 — Toujours charge (~5%)
KERNEL.md, PATHS.md, brain-compose.local.yml
→ L'identite du brain. Non negociable.
L1 — Selon le type de session (~10-18%)
Les agents et fichiers specifiques a CE type de session.
→ work charge debug + coach, deploy charge vps + ci-cd, etc.
L2 — Selon le projet (~5-15%)
Si tu declares un projet dans ta commande, ses fichiers sont charges.
→ projets/<nom>.md + todo/<nom>.md
L3 — Sur demande (0% au boot)
Tout le reste. Charge en cours de session si tu en as besoin.
→ "Charge l'agent testing" → L3 → disponible
```
**Resultat** : ~20-30% du contexte utilise au boot, au lieu de 80%. La session demarre vite.
---
## Ce que chaque session peut / ne peut pas faire
**Sessions projet** — ecrivent dans le code, pas dans le brain :
- `work` · `debug` · `deploy` · `infra` · `urgence` — ecriture projet uniquement
**Sessions brain** — ecrivent dans le brain, pas dans le code :
- `brain` — agents, profil (gate humain sur le kernel)
- `edit-brain`**ecriture kernel autorisee** (gate humain obligatoire)
- `kernel` — lecture seule, aucune ecriture
**Sessions mixtes** :
- `pilote` — ecriture projet + brain (gates architecturaux sur les forks irreversibles)
**Sessions legeres** — ecriture limitee ou aucune :
- `brainstorm` — todo seulement
- `navigate` — aucune ecriture
- `coach` — progression seulement
- `capital` — profil seulement
- `audit` — rapport seul
- `handoff` — herite du handoff
---
## Ce qui se passe quand tu fermes une session
Quand tu dis `fin`, `on wrappe` ou `c'est bon`, le brain lance une sequence de fermeture automatique :
```
1. Metriques → metabolism-scribe capture tokens, duree, commits, health_score
2. Todos → todo-scribe ferme les ✅ et capture les nouveaux ⬜
3. Wiki → wiki-scribe ajoute les nouveaux termes si besoin
4. Brain update → scribe met a jour focus, projets, agents si changement
5. Coach → rapport de session (sauf en navigate, deploy, infra, urgence, audit)
6. BSI close → le claim est ferme, la session est tracee
```
**Pas toutes les etapes a chaque fois.** Le brain adapte selon le type de session :
- **navigate** : juste metriques + BSI close (session legere)
- **work** : metriques + todos + scribe + coach + BSI close (session complete)
- **brainstorm** : metriques + todos emerges + BSI close (pas de commit attendu)
- **pilote** : tout — metriques + todos + wiki + scribe + coach + BSI close
Le coach ne fait pas de rapport en session silencieuse (navigate, deploy, infra, urgence, audit) — il n'intervient que si risque critique.
---
## Le metabolisme — ce qu'on mesure
A la fin de chaque session, le `metabolism-scribe` capture des metriques :
- **tokens_used** : combien de tokens consommes
- **context_peak** : pic d'utilisation du contexte (%)
- **duration_min** : duree de la session
- **commits** : nombre de commits produits
- **todos_closed** : todos coches pendant la session
- **health_score** : score calcule — se lit en tendance sur 7 jours
Le score n'est pas un jugement. Il detecte les patterns :
- Score bas + context haut = session qui consomme sans produire
- Score bas sur un brainstorm = normal (pas de livrable attendu)
- Ratio use-brain/build-brain < 0.5 sur 7j = trop de travail sur le brain, pas assez de production
### Les 3 profils de scoring
Toutes les sessions ne se mesurent pas pareil :
| Profil | Sessions | Ce qui compte |
|--------|----------|--------------|
| **Productif** | work, deploy, debug, infra, urgence | Todos fermes, commits |
| **Constructif** | brain, edit-brain, kernel, pilote | Fichiers kernel touches, ADRs |
| **Exploratoire** | brainstorm, navigate, coach, capital, handoff, audit | Insights captures, duree |
---
## Les tiers
Le brain a un systeme de tiers qui controle l'acces aux agents et aux sessions :
> 🟢 **free** — 6 sessions (work, debug, brainstorm, brain, navigate, handoff). Pas de cle API. Le brain fonctionne quand meme.
> 🔵 **featured** — +2 sessions (coach, capital). Progression personnelle, RAG, coaching complet.
> 🟠 **pro** — +4 sessions (audit, deploy, infra, urgence). Tous les agents metier : code-review, security, vps, ci-cd, monitoring.
> 🟣 **full** — +3 sessions (kernel, edit-brain, pilote). Tous les agents, acces kernel complet, owner du brain.
→ Detail complet : voir **Agents & Tiers** dans la sidebar.
---
## FAQ
### Comment creer un nouveau type de session ?
1. Creer `contexts/session-<type>.yml` avec le format L0/L1/L2/L3
2. Declarer le tier_required et le context_target
3. Ajouter le type dans `brain-compose.yml` > `feature_sets` > le bon tier
4. Ajouter le handoff_default dans `manifest.yml`
5. Ajouter la zone access dans `KERNEL.md`
6. Mettre a jour `wiki/session-matrix.md`
### Comment escalader depuis navigate ?
Dis simplement `brain boot mode <type>` (ex: `brain boot mode work/superoauth`). Le brain ferme navigate et ouvre la session demandee. Tu peux aussi decrire ce que tu veux faire — le brain detectera le debordement et proposera le bon type.
### Pourquoi ma session est en mode conserve ?
Le mode conserve se declenche quand :
- Le contexte depasse 70% ET le health_score est < 1.0
- Le contexte a la fermeture depasse 60%
- C'est une session urgence (conserve automatique)
En mode conserve, le brain cible < 40% de contexte et ne charge que l'essentiel.
### C'est quoi un handoff ?
Un handoff est un fichier qui capture l'etat d'une session pour qu'une autre puisse reprendre. Niveaux :
- **NO** : pas de handoff — la prochaine session repart de zero (cold start)
- **SEMI** : Layer 0 + position
- **SEMI+** : SEMI + focus + projet
- **FULL** : tout le contexte de reprise
---
## Liens
- **Reference technique** : `wiki/session-matrix.md` — matrice complete avec tous les champs
- **Cycle de vie** : `wiki/session-lifecycle.md` — boot → work → close en detail
- **Context loading** : `wiki/context-loading.md` — architecture BHP L0-L3
- **Metabolisme** : `profil/metabolism-spec.md` — formules et seuils

41
docs/vue-featured.md Normal file
View File

@@ -0,0 +1,41 @@
# 🔵 featured — Ce que tu as
> 🔵 **18 agents + systeme. 8 sessions. Le brain te connait.**
Tu as tout ce qui est en free, plus :
---
## Sessions ajoutees
- `coach` — progression, reflexion strategique
- `capital` — bilan professionnel, objectifs, CV
---
## Agents ajoutes
- `coach` (complet) — remplace `coach-boot`. Bilans de session, objectifs SMART, progression tracee.
- `coach-scribe` — persiste la progression (journal, skills, milestones)
- `capital-scribe` — transforme tes milestones en formulations CV
- `progression-scribe` — suivi progression detaille
---
## Capacite debloquee
> 🔵 **Distillation RAG** — le brain enrichit son contexte a chaque session. Il se souvient de tes acquis entre sessions. Plus tu l'utilises, mieux il te connait.
---
## Coach
> 🔵 Bilans de session, objectifs concrets, progression tracee dans `progression/`. Le coach passe de spectateur a mentor.
---
## Ce que tu n'as pas encore
> 🟠 **pro** te donne : code-review (7 priorites), security (8 audits OWASP), testing, refacto, 3 optimiseurs perf, deploy VPS + CI/CD, monitoring, sessions urgence/infra.
→ Detail : voir la vue par tier dans la sidebar.

69
docs/vue-free.md Normal file
View File

@@ -0,0 +1,69 @@
# 🟢 free — Ce que tu as
> 🟢 **14 agents invocables + 8 systeme. 6 sessions. Pas de cle, pas de config.**
---
## Sessions
- `navigate` — orientation, vue d'ensemble
- `work` — developpement projet
- `debug` — investigation bug
- `brainstorm` — explorer, challenger, structurer
- `brain` — travailler sur le brain lui-meme
- `handoff` — reprendre une session precedente
---
## Agents invocables
**Travailler**
- `debug` — methode 5 etapes, bugs locaux et prod
- `mentor` — explications pedagogiques, garde-fou
- `brainstorm` — exploration et structuration de decisions
- `orchestrator` — coordination multi-agents
- `interprete` — clarification d'intention, scope drift
**Maintenir le brain**
- `scribe` — maintenance brain (focus, projets, agents)
- `todo-scribe` — persistance intentions (brain/todo/)
- `recruiter` — creer de nouveaux agents specialises
- `agent-review` — auditer le systeme d'agents
- `brain-guardian` — auto-mefiance quand le brain s'edite
- `aside` — parenthese /btw en session
- `pattern-scribe` — detection patterns recurrents
- `time-anchor` — conscience temporelle, recontextualisation
---
## Agents systeme
Ces agents tournent a chaque boot, quel que soit le tier :
- `helloWorld` — briefing, claim BSI
- `coach-boot` — observation legere, risque critique uniquement
- `secrets-guardian` — surveillance secrets permanente
- `session-orchestrator` — lifecycle boot → work → close
- `metabolism-scribe` — metriques de session
- `wiki-scribe` — documentation wiki/ + docs/
- `key-guardian` — validation API key (absent → free silencieux)
- `pre-flight` — verification tier/session avant chargement
- `feature-gate` — enforcement tiers runtime
---
## Coach
> 🟢 Observe en arriere-plan. Intervient uniquement sur un risque critique. Pas de bilan, pas d'objectifs.
---
## Ce que tu n'as pas encore
> 🔵 **featured** te donne : coach complet avec bilans + objectifs, distillation RAG (le brain se souvient), suivi de progression.
> 🟠 **pro** te donne : review code, audit securite, tests, deploy prod, 3 optimiseurs perf, monitoring.
→ Detail : voir la vue par tier dans la sidebar.

65
docs/vue-full.md Normal file
View File

@@ -0,0 +1,65 @@
# 🟣 full — Ce que tu as
> 🟣 **75 agents (tous). 15 sessions. Tu es owner.**
Tu as tout ce qui est en pro, plus : tous les agents restants.
---
## Sessions ajoutees
- `kernel` — lecture seule du kernel (audit, diagnostic)
- `edit-brain` — modification kernel (gate humain obligatoire)
- `pilote` — co-construction longue, copilotage proactif
---
## Agents ajoutes (selection)
**Supervision**
- `brain-hypervisor` — supervision multi-phase, drift detection
- `kernel-orchestrator` — workflows BSI, circuit breaker
**Contenu**
- `content-orchestrator` — detecte les signaux content, active les agents
- `content-strategist` — strategie YouTube, angle, audience
- `scriptwriter` — scripts video (short 60s + long 12min)
- `seo-youtube` — SEO + thumbnail brief
**Conception**
- `game-designer` — mecanique, equilibrage, progression
- `product-strategist` — business model, monetisation
- `spec-scribe` — specs techniques structurees
**Orchestration avancee**
- `supervisor` — coordination dual-agent, CHECKPOINT
- `context-broker` — cycle respiratoire de contexte
- `satellite-boot` — boot loader pour instances satellites
- `workflow-auditor` — retrospective workflow, KPIs
- `diagram-scribe` — dashboard workflow live
---
## Capacites debloquees
> 🟣 **Ecriture kernel** — modifier KERNEL.md, CLAUDE.md, agents/ (gate humain obligatoire a chaque modification)
> 🟣 **Mode pilote** — copilotage proactif sur sessions longues. Le coach anticipe les bifurcations.
> 🟣 **Distillation L2** — coaching long terme avec contexte accumule (BACT). Le coach connait ton historique complet.
---
## Coach
> 🟣 Mentorat long terme. Anticipe les bifurcations, challenge les decisions, milestones sur plusieurs mois. Le coach connait ton historique complet.
---
## Tu as tout
C'est ton brain. Tu peux modifier n'importe quel agent, forger les tiens, restructurer le kernel. Le seul gate c'est toi — confirmation humaine obligatoire sur les modifications kernel.

65
docs/vue-pro.md Normal file
View File

@@ -0,0 +1,65 @@
# 🟠 pro — Ce que tu as
> 🟠 **40 agents + systeme. 12 sessions. Tu ship en prod.**
Tu as tout ce qui est en featured, plus :
---
## Sessions ajoutees
- `audit` — analyse lecture seule, rapport qualite/secu
- `deploy` — deploiement prod, config VPS
- `infra` — maintenance VPS, monitoring
- `urgence` — incident prod, hotfix critique
---
## Agents ajoutes
**Code & Qualite**
- `code-review` — review selon 7 priorites de vigilance
- `security` — audit OWASP, 8 priorites
- `testing` — tests Jest/Vitest, strategie par couche DDD
- `refacto` — restructuration en 5 etapes, 3 niveaux de risque
- `frontend-stack` — architecture frontend, stack, libs UI
**Performance — le trio**
- `optimizer-backend` — perf Node.js (async, memoire, event loop)
- `optimizer-db` — perf MySQL (N+1, index, EXPLAIN)
- `optimizer-frontend` — perf React (re-renders, bundle, lazy loading)
**Infra & Deploy**
- `vps` — deploy Docker + Apache + SSL de A a Z
- `ci-cd` — pipelines GitHub Actions / Gitea CI
- `monitoring` — sondes Uptime Kuma, alertes, logs
- `pm2` — process manager Node.js prod
- `mail` — Stalwart, DNS, SMTP/IMAP
- `migration` — TypeORM migrations safe
**Documentation & Outils**
- `i18n` — internationalisation, audit traductions
- `doc` — README, API Swagger
- `toolkit-scribe` — capture patterns valides → toolkit/
- `git-analyst` — historique git semantique
- `brain-compose` — gestion multi-instances
- `config-scribe` — configuration brain
- `audit` — diagnostic coherence brain
---
## Coach
> 🟠 Idem featured + contexte projet. Le coach connait ta stack et tes patterns.
---
## Ce que tu n'as pas encore
> 🟣 **full** te donne : ecriture kernel, mode pilote (copilotage long), supervision multi-phase, contenu YouTube pipeline, game design, distillation L2.
→ Detail : voir la vue par tier dans la sidebar.

172
docs/vue-tiers.md Normal file
View File

@@ -0,0 +1,172 @@
# Vue par tier
> Meme agents, autre angle. Ici tu vois tout ce qui est disponible a TON niveau.
> Pour la vue par specialite → Code & Qualite, Infra & Deploy, Brain & Systeme dans la sidebar.
---
## 🟢 free — Ce que tu as
> 🟢 **14 agents invocables + 8 systeme. 6 sessions.**
### Sessions
- `navigate` — orientation, vue d'ensemble
- `work` — developpement projet
- `debug` — investigation bug
- `brainstorm` — explorer, challenger, structurer
- `brain` — travailler sur le brain lui-meme
- `handoff` — reprendre une session precedente
### Agents invocables
**Travailler**
- `debug` — methode 5 etapes, bugs locaux et prod
- `mentor` — explications pedagogiques, garde-fou
- `brainstorm` — exploration et structuration de decisions
- `orchestrator` — coordination multi-agents
- `interprete` — clarification d'intention, scope drift
**Maintenir le brain**
- `scribe` — maintenance brain (focus, projets, agents)
- `todo-scribe` — persistance intentions (brain/todo/)
- `recruiter` — creer de nouveaux agents specialises
- `agent-review` — auditer le systeme d'agents
- `brain-guardian` — auto-mefiance quand le brain s'edite
- `aside` — parenthese /btw en session
- `pattern-scribe` — detection patterns recurrents
- `time-anchor` — conscience temporelle, recontextualisation
### Agents systeme (tournent a chaque boot)
- `helloWorld` — briefing, claim BSI
- `coach-boot` — observation legere, risque critique uniquement
- `secrets-guardian` — surveillance secrets permanente
- `session-orchestrator` — lifecycle boot → work → close
- `metabolism-scribe` — metriques de session
- `wiki-scribe` — documentation wiki/ + docs/
- `key-guardian` — validation API key (absent → free silencieux)
- `pre-flight` — verification tier/session avant chargement
- `feature-gate` — enforcement tiers runtime
### Coach
> 🟢 Observe en arriere-plan. Intervient uniquement sur un risque critique. Pas de bilan, pas d'objectifs.
---
## 🔵 featured — Ce que tu gagnes
> 🔵 **Tout ce qui est en free, plus :**
### Sessions ajoutees
- `coach` — progression, reflexion strategique
- `capital` — bilan professionnel, objectifs, CV
### Agents ajoutes
- `coach` (complet) — remplace `coach-boot`. Bilans de session, objectifs SMART, progression tracee.
- `coach-scribe` — persiste la progression (journal, skills, milestones)
- `capital-scribe` — transforme tes milestones en formulations CV
- `progression-scribe` — suivi progression detaille
### Capacite debloquee
- **Distillation RAG** — le brain enrichit son contexte a chaque session. Il se souvient de tes acquis.
### Coach
> 🔵 Bilans de session, objectifs concrets, progression tracee dans `progression/`. Le coach passe de spectateur a mentor.
---
## 🟠 pro — Ce que tu gagnes
> 🟠 **Tout ce qui est en featured, plus :**
### Sessions ajoutees
- `audit` — analyse lecture seule, rapport qualite/secu
- `deploy` — deploiement prod, config VPS
- `infra` — maintenance VPS, monitoring
- `urgence` — incident prod, hotfix critique
### Agents ajoutes
**Code & Qualite**
- `code-review` — review selon 7 priorites de vigilance
- `security` — audit OWASP, 8 priorites
- `testing` — tests Jest/Vitest, strategie par couche DDD
- `refacto` — restructuration en 5 etapes, 3 niveaux de risque
- `optimizer-backend` — perf Node.js (async, memoire, event loop)
- `optimizer-db` — perf MySQL (N+1, index, EXPLAIN)
- `optimizer-frontend` — perf React (re-renders, bundle, lazy loading)
- `frontend-stack` — architecture frontend, stack, libs UI
**Infra & Deploy**
- `vps` — deploy Docker + Apache + SSL de A a Z
- `ci-cd` — pipelines GitHub Actions / Gitea CI
- `monitoring` — sondes Uptime Kuma, alertes, logs
- `pm2` — process manager Node.js prod
- `mail` — Stalwart, DNS, SMTP/IMAP
- `migration` — TypeORM migrations safe
**Documentation & Outils**
- `i18n` — internationalisation, audit traductions
- `doc` — README, API Swagger
- `toolkit-scribe` — capture patterns valides → toolkit/
- `git-analyst` — historique git semantique
- `brain-compose` — gestion multi-instances
- `config-scribe` — configuration brain
- `audit` — diagnostic coherence brain
### Coach
> 🟠 Idem featured + contexte projet. Le coach connait ta stack et tes patterns.
---
## 🟣 full — Ce que tu gagnes
> 🟣 **Tout ce qui est en pro, plus : tous les agents restants.**
### Sessions ajoutees
- `kernel` — lecture seule du kernel (audit, diagnostic)
- `edit-brain` — modification kernel (gate humain obligatoire)
- `pilote` — co-construction longue, copilotage proactif
### Agents ajoutes (selection)
**Supervision**
- `brain-hypervisor` — supervision multi-phase, drift detection
- `kernel-orchestrator` — workflows BSI, circuit breaker
**Contenu**
- `content-orchestrator` — detecte les signaux content, active les agents
- `content-strategist` — strategie YouTube, angle, audience
- `scriptwriter` — scripts video (short 60s + long 12min)
- `seo-youtube` — SEO + thumbnail brief
**Conception**
- `game-designer` — mecanique, equilibrage, progression
- `product-strategist` — business model, monetisation
- `spec-scribe` — specs techniques structurees
**Orchestration avancee**
- `supervisor` — coordination dual-agent, CHECKPOINT
- `context-broker` — cycle respiratoire de contexte
- `satellite-boot` — boot loader pour instances satellites
- `workflow-auditor` — retrospective workflow, KPIs
- `diagram-scribe` — dashboard workflow live
### Capacites debloquees
- **Ecriture kernel** — modifier KERNEL.md, CLAUDE.md, agents/ (gate humain)
- **Mode pilote** — copilotage proactif sur sessions longues
- **Distillation L2** — coaching long terme avec contexte accumule (BACT)
### Coach
> 🟣 Mentorat long terme. Anticipe les bifurcations, challenge les decisions, milestones sur plusieurs mois. Le coach connait ton historique complet.

207
docs/workflows.md Normal file
View File

@@ -0,0 +1,207 @@
# Workflows — les recettes d'agents
> Quels agents combiner, pour quel resultat. Les combinaisons testees et validees.
---
## Quotidien
### Coder sur un projet
```
brain boot mode work/mon-projet
```
> 🟢 **free**
Agents actifs : `debug`, `scribe`, `todo-scribe`. Le brain detecte ce que tu fais et charge les agents supplementaires si besoin.
---
### Debugger un bug
```
brain boot mode debug/mon-projet
```
> 🟢 **free**
Agent principal : `debug` — methode en 5 etapes (reproduire → isoler → hypotheses → verifier → corriger). Si le bug touche l'infra → delegue a `vps`.
---
### Explorer une idee
```
brain boot mode brainstorm/sujet
```
> 🟢 **free**
Agent principal : `brainstorm` — avocat du diable, challenge tes decisions. Pas de livrable attendu — les insights sont captures en todo si actionnable.
---
## Avant de shipper
### Review code + securite
```
Charge les agents code-review et security
```
> 🟠 **pro**
`code-review` analyse selon 7 priorites. Si un finding critique est detecte → `security` prend le relais pour l'audit OWASP. Apres → `testing` pour couvrir les corrections.
**Recette complete avant prod :**
```
Charge les agents security, code-review et testing
```
Les 3 travaillent en sequence : securite → qualite → tests.
---
### Audit perf full-stack — le trio
```
Charge les agents optimizer-backend, optimizer-db et optimizer-frontend
```
> 🟠 **pro**
Le trio Riri Fifi Loulou :
- `optimizer-backend` — async, memoire, event loop Node.js
- `optimizer-db` — N+1, index manquants, EXPLAIN
- `optimizer-frontend` — re-renders, bundle, lazy loading
Chacun sait ce qu'il ne couvre pas et delegue aux deux autres.
---
## Deploy
### Deployer un nouveau service
```
brain boot mode deploy/mon-projet
```
> 🟠 **pro**
Agents actifs : `vps` + `ci-cd`. Le workflow :
1. `vps` deploie le service (Docker + Apache + SSL)
2. `ci-cd` cree le pipeline (GitHub Actions ou Gitea CI)
3. `monitoring` suggere une sonde post-deploy
---
### Deployer un service mail
```
brain boot mode deploy
Charge les agents vps et mail
```
> 🟠 **pro**
`vps` gere le serveur (container Stalwart, vhost Apache), `mail` gere le protocole (SMTP, IMAP, DNS, SPF, DKIM).
---
## Refacto
### Refacto securisee
```
Charge les agents refacto et testing
```
> 🟠 **pro**
1. `testing` ecrit les tests avant la refacto (filet de securite)
2. `refacto` restructure par etapes (tests verts a chaque etape)
3. `code-review` valide le resultat
**Regle :** pas de tests → pas de refacto niveau 2/3.
---
## Incidents
### Bug en prod
```
brain boot mode urgence
```
> 🟠 **pro**
Agents actifs : `debug` + `vps`. Mode conserve automatique (economie de contexte). `debug` isole le probleme, `vps` intervient si c'est infra.
---
### Incident complexe
```
brain boot mode urgence
Charge les agents monitoring, vps et debug
```
> 🟠 **pro**
`monitoring` lit les alertes et logs → `vps` diagnostique l'infra → `debug` isole le bug applicatif. Sequence : alertes → infra → code.
---
## Brain
### Forger un nouvel agent
```
brain boot mode brain
Charge l'agent recruiter
```
> 🟢 **free**
`recruiter` concoit l'agent : domaine, perimetre, composition, anti-hallucination. Il produit le fichier `.md` complet. `agent-review` peut ensuite l'auditer.
---
### Auditer le systeme d'agents
```
brain boot mode brain
Charge l'agent agent-review
```
> 🟢 **free**
`agent-review` detecte les gaps, les overlaps, et les agents qui ne font pas ce qu'ils promettent. Si un gap est trouve → `recruiter` forge l'agent manquant.
---
### Session pilote (copilotage long)
```
brain boot mode pilote
```
> 🟣 **full**
Le coach est proactif — il anticipe les bifurcations et challenge les decisions. Tous les scribes sont actifs. Contexte max (~35%).
---
## Les combos par tier
> 🟢 **free** — debug seul, brainstorm seul, forger des agents, auditer le systeme
> 🔵 **featured** — tout ce qui est free + sessions coach avec bilans et objectifs
> 🟠 **pro** — review + securite + tests, trio perf, deploy complet, incidents, refacto securisee
> 🟣 **full** — pilotage long, supervision multi-phase, contenu YouTube, modification kernel