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:
@@ -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*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
77
brain-engine/README.md
Normal 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
401
brain-engine/distill.py
Normal 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
524
brain-engine/embed.py
Normal 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
412
brain-engine/mcp_server.py
Normal 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
348
brain-engine/migrate.py
Normal 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()
|
||||||
30
brain-engine/queries/cold-start-kpi.sql
Normal file
30
brain-engine/queries/cold-start-kpi.sql
Normal 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;
|
||||||
16
brain-engine/queries/graduation-candidates.sql
Normal file
16
brain-engine/queries/graduation-candidates.sql
Normal 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;
|
||||||
24
brain-engine/queries/metabolism-dashboard.sql
Normal file
24
brain-engine/queries/metabolism-dashboard.sql
Normal 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');
|
||||||
12
brain-engine/queries/stale-claims.sql
Normal file
12
brain-engine/queries/stale-claims.sql
Normal 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
190
brain-engine/rag.py
Normal 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()
|
||||||
6
brain-engine/requirements.txt
Normal file
6
brain-engine/requirements.txt
Normal 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
188
brain-engine/schema.sql
Normal 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
227
brain-engine/search.py
Normal 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.0–1.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
1531
brain-engine/server.py
Normal file
File diff suppressed because it is too large
Load Diff
74
brain-engine/start.sh
Executable file
74
brain-engine/start.sh
Executable 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"
|
||||||
1312
brain-engine/test_brain_engine.py
Normal file
1312
brain-engine/test_brain_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
33
brain-ui/build.sh
Executable file
33
brain-ui/build.sh
Executable 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
12
brain-ui/index.html
Normal 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
34
brain-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
brain-ui/public/docs/README.md
Symbolic link
1
brain-ui/public/docs/README.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/README.md
|
||||||
1
brain-ui/public/docs/agents-brain.md
Symbolic link
1
brain-ui/public/docs/agents-brain.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/agents-brain.md
|
||||||
1
brain-ui/public/docs/agents-code.md
Symbolic link
1
brain-ui/public/docs/agents-code.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/agents-code.md
|
||||||
1
brain-ui/public/docs/agents-infra.md
Symbolic link
1
brain-ui/public/docs/agents-infra.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/agents-infra.md
|
||||||
1
brain-ui/public/docs/agents.md
Symbolic link
1
brain-ui/public/docs/agents.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/agents.md
|
||||||
1
brain-ui/public/docs/architecture.md
Symbolic link
1
brain-ui/public/docs/architecture.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/architecture.md
|
||||||
1
brain-ui/public/docs/getting-started.md
Symbolic link
1
brain-ui/public/docs/getting-started.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/getting-started.md
|
||||||
1
brain-ui/public/docs/sessions.md
Symbolic link
1
brain-ui/public/docs/sessions.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/sessions.md
|
||||||
1
brain-ui/public/docs/vue-featured.md
Symbolic link
1
brain-ui/public/docs/vue-featured.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/vue-featured.md
|
||||||
1
brain-ui/public/docs/vue-free.md
Symbolic link
1
brain-ui/public/docs/vue-free.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/vue-free.md
|
||||||
1
brain-ui/public/docs/vue-full.md
Symbolic link
1
brain-ui/public/docs/vue-full.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/vue-full.md
|
||||||
1
brain-ui/public/docs/vue-pro.md
Symbolic link
1
brain-ui/public/docs/vue-pro.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/vue-pro.md
|
||||||
1
brain-ui/public/docs/vue-tiers.md
Symbolic link
1
brain-ui/public/docs/vue-tiers.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/vue-tiers.md
|
||||||
1
brain-ui/public/docs/workflows.md
Symbolic link
1
brain-ui/public/docs/workflows.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/workflows.md
|
||||||
301
brain-ui/src/App.tsx
Normal file
301
brain-ui/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
brain-ui/src/components/CommandPalette.tsx
Normal file
190
brain-ui/src/components/CommandPalette.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
brain-ui/src/components/DocsView.tsx
Normal file
155
brain-ui/src/components/DocsView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
271
brain-ui/src/components/GateDrawer.tsx
Normal file
271
brain-ui/src/components/GateDrawer.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
brain-ui/src/components/GatesDrawer.tsx
Normal file
128
brain-ui/src/components/GatesDrawer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
brain-ui/src/components/InfraRegistry.tsx
Normal file
121
brain-ui/src/components/InfraRegistry.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
brain-ui/src/components/LogDrawer.tsx
Normal file
202
brain-ui/src/components/LogDrawer.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
288
brain-ui/src/components/SecretsZone.tsx
Normal file
288
brain-ui/src/components/SecretsZone.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
brain-ui/src/components/StepNode.tsx
Normal file
128
brain-ui/src/components/StepNode.tsx
Normal 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)
|
||||||
122
brain-ui/src/components/TeamSelector.tsx
Normal file
122
brain-ui/src/components/TeamSelector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
brain-ui/src/components/TierGate.tsx
Normal file
21
brain-ui/src/components/TierGate.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
211
brain-ui/src/components/ToastProvider.tsx
Normal file
211
brain-ui/src/components/ToastProvider.tsx
Normal 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
|
||||||
|
}
|
||||||
208
brain-ui/src/components/WorkflowBoard.tsx
Normal file
208
brain-ui/src/components/WorkflowBoard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
283
brain-ui/src/components/WorkflowBuilder.tsx
Normal file
283
brain-ui/src/components/WorkflowBuilder.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
brain-ui/src/components/cosmos/CosmosControls.tsx
Normal file
107
brain-ui/src/components/cosmos/CosmosControls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
brain-ui/src/components/cosmos/CosmosInfoPanel.tsx
Normal file
212
brain-ui/src/components/cosmos/CosmosInfoPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
brain-ui/src/components/cosmos/CosmosMetrics.tsx
Normal file
91
brain-ui/src/components/cosmos/CosmosMetrics.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
150
brain-ui/src/components/cosmos/CosmosPoints.tsx
Normal file
150
brain-ui/src/components/cosmos/CosmosPoints.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
brain-ui/src/components/cosmos/CosmosScene.tsx
Normal file
41
brain-ui/src/components/cosmos/CosmosScene.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
brain-ui/src/components/cosmos/CosmosView.tsx
Normal file
178
brain-ui/src/components/cosmos/CosmosView.tsx
Normal 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 />
|
||||||
|
}
|
||||||
64
brain-ui/src/components/workspace/CosmosBackground.tsx
Normal file
64
brain-ui/src/components/workspace/CosmosBackground.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
brain-ui/src/components/workspace/GateOctahedron.tsx
Normal file
39
brain-ui/src/components/workspace/GateOctahedron.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
brain-ui/src/components/workspace/StepSphere.tsx
Normal file
43
brain-ui/src/components/workspace/StepSphere.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
brain-ui/src/components/workspace/WorkflowConstellation.tsx
Normal file
91
brain-ui/src/components/workspace/WorkflowConstellation.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
brain-ui/src/components/workspace/WorkspaceInfoPanel.tsx
Normal file
149
brain-ui/src/components/workspace/WorkspaceInfoPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
brain-ui/src/components/workspace/WorkspaceMetrics.tsx
Normal file
59
brain-ui/src/components/workspace/WorkspaceMetrics.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
brain-ui/src/components/workspace/WorkspaceView.tsx
Normal file
120
brain-ui/src/components/workspace/WorkspaceView.tsx
Normal 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 />
|
||||||
|
}
|
||||||
104
brain-ui/src/hooks/useCosmosData.ts
Normal file
104
brain-ui/src/hooks/useCosmosData.ts
Normal 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) }
|
||||||
|
}
|
||||||
63
brain-ui/src/hooks/useInfra.ts
Normal file
63
brain-ui/src/hooks/useInfra.ts
Normal 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 }
|
||||||
|
}
|
||||||
50
brain-ui/src/hooks/useLogs.ts
Normal file
50
brain-ui/src/hooks/useLogs.ts
Normal 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
|
||||||
|
}
|
||||||
96
brain-ui/src/hooks/useTeams.ts
Normal file
96
brain-ui/src/hooks/useTeams.ts
Normal 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 }
|
||||||
|
}
|
||||||
39
brain-ui/src/hooks/useTier.ts
Normal file
39
brain-ui/src/hooks/useTier.ts
Normal 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 }
|
||||||
|
}
|
||||||
161
brain-ui/src/hooks/useWebSocket.ts
Normal file
161
brain-ui/src/hooks/useWebSocket.ts
Normal 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'
|
||||||
|
}
|
||||||
34
brain-ui/src/hooks/useWorkflows.ts
Normal file
34
brain-ui/src/hooks/useWorkflows.ts
Normal 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 }
|
||||||
|
}
|
||||||
33
brain-ui/src/hooks/useWorkspaceData.ts
Normal file
33
brain-ui/src/hooks/useWorkspaceData.ts
Normal 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
45
brain-ui/src/index.css
Normal 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
10
brain-ui/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
36
brain-ui/src/store/brain.store.ts
Normal file
36
brain-ui/src/store/brain.store.ts
Normal 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
105
brain-ui/src/types/index.ts
Normal 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
11
brain-ui/src/vite-env.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
20
brain-ui/tailwind.config.js
Normal file
20
brain-ui/tailwind.config.js
Normal 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
15
brain-ui/tsconfig.json
Normal 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
19
brain-ui/vite.config.ts
Normal 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
37
docs/README.md
Normal 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
170
docs/agents-brain.md
Normal 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
112
docs/agents-code.md
Normal 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
87
docs/agents-infra.md
Normal 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
91
docs/agents.md
Normal 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
165
docs/architecture.md
Normal 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
135
docs/getting-started.md
Normal 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
224
docs/sessions.md
Normal 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
41
docs/vue-featured.md
Normal 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
69
docs/vue-free.md
Normal 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
65
docs/vue-full.md
Normal 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
65
docs/vue-pro.md
Normal 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
172
docs/vue-tiers.md
Normal 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
207
docs/workflows.md
Normal 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
|
||||||
Reference in New Issue
Block a user