From 8244a078810a4dbc4ba901f1a162d4d2b8d20a63 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Fri, 20 Mar 2026 20:25:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20brain-engine=20+=20brain-ui=20+=20docs?= =?UTF-8?q?=20=E2=80=94=20template=20full=20stack=20standalone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- DISTRIBUTION_CHECKLIST.md | 50 +- brain-compose.local.yml.example | 43 +- brain-compose.yml | 110 +- brain-engine/README.md | 77 + brain-engine/distill.py | 401 +++++ brain-engine/embed.py | 524 ++++++ brain-engine/mcp_server.py | 412 +++++ brain-engine/migrate.py | 348 ++++ brain-engine/queries/cold-start-kpi.sql | 30 + .../queries/graduation-candidates.sql | 16 + brain-engine/queries/metabolism-dashboard.sql | 24 + brain-engine/queries/stale-claims.sql | 12 + brain-engine/rag.py | 190 ++ brain-engine/requirements.txt | 6 + brain-engine/schema.sql | 188 ++ brain-engine/search.py | 227 +++ brain-engine/server.py | 1531 +++++++++++++++++ brain-engine/start.sh | 74 + brain-engine/test_brain_engine.py | 1312 ++++++++++++++ brain-ui/build.sh | 33 + brain-ui/index.html | 12 + brain-ui/package.json | 34 + brain-ui/public/docs/README.md | 1 + brain-ui/public/docs/agents-brain.md | 1 + brain-ui/public/docs/agents-code.md | 1 + brain-ui/public/docs/agents-infra.md | 1 + brain-ui/public/docs/agents.md | 1 + brain-ui/public/docs/architecture.md | 1 + brain-ui/public/docs/getting-started.md | 1 + brain-ui/public/docs/sessions.md | 1 + brain-ui/public/docs/vue-featured.md | 1 + brain-ui/public/docs/vue-free.md | 1 + brain-ui/public/docs/vue-full.md | 1 + brain-ui/public/docs/vue-pro.md | 1 + brain-ui/public/docs/vue-tiers.md | 1 + brain-ui/public/docs/workflows.md | 1 + brain-ui/src/App.tsx | 301 ++++ brain-ui/src/components/CommandPalette.tsx | 190 ++ brain-ui/src/components/DocsView.tsx | 155 ++ brain-ui/src/components/GateDrawer.tsx | 271 +++ brain-ui/src/components/GatesDrawer.tsx | 128 ++ brain-ui/src/components/InfraRegistry.tsx | 121 ++ brain-ui/src/components/LogDrawer.tsx | 202 +++ brain-ui/src/components/SecretsZone.tsx | 288 ++++ brain-ui/src/components/StepNode.tsx | 128 ++ brain-ui/src/components/TeamSelector.tsx | 122 ++ brain-ui/src/components/TierGate.tsx | 21 + brain-ui/src/components/ToastProvider.tsx | 211 +++ brain-ui/src/components/WorkflowBoard.tsx | 208 +++ brain-ui/src/components/WorkflowBuilder.tsx | 283 +++ .../src/components/cosmos/CosmosControls.tsx | 107 ++ .../src/components/cosmos/CosmosInfoPanel.tsx | 212 +++ .../src/components/cosmos/CosmosMetrics.tsx | 91 + .../src/components/cosmos/CosmosPoints.tsx | 150 ++ .../src/components/cosmos/CosmosScene.tsx | 41 + brain-ui/src/components/cosmos/CosmosView.tsx | 178 ++ .../components/workspace/CosmosBackground.tsx | 64 + .../components/workspace/GateOctahedron.tsx | 39 + .../src/components/workspace/StepSphere.tsx | 43 + .../workspace/WorkflowConstellation.tsx | 91 + .../workspace/WorkspaceInfoPanel.tsx | 149 ++ .../components/workspace/WorkspaceMetrics.tsx | 59 + .../components/workspace/WorkspaceView.tsx | 120 ++ brain-ui/src/hooks/useCosmosData.ts | 104 ++ brain-ui/src/hooks/useInfra.ts | 63 + brain-ui/src/hooks/useLogs.ts | 50 + brain-ui/src/hooks/useTeams.ts | 96 ++ brain-ui/src/hooks/useTier.ts | 39 + brain-ui/src/hooks/useWebSocket.ts | 161 ++ brain-ui/src/hooks/useWorkflows.ts | 34 + brain-ui/src/hooks/useWorkspaceData.ts | 33 + brain-ui/src/index.css | 45 + brain-ui/src/main.tsx | 10 + brain-ui/src/store/brain.store.ts | 36 + brain-ui/src/types/index.ts | 105 ++ brain-ui/src/vite-env.d.ts | 11 + brain-ui/tailwind.config.js | 20 + brain-ui/tsconfig.json | 15 + brain-ui/vite.config.ts | 19 + docs/README.md | 37 + docs/agents-brain.md | 170 ++ docs/agents-code.md | 112 ++ docs/agents-infra.md | 87 + docs/agents.md | 91 + docs/architecture.md | 165 ++ docs/getting-started.md | 135 ++ docs/sessions.md | 224 +++ docs/vue-featured.md | 41 + docs/vue-free.md | 69 + docs/vue-full.md | 65 + docs/vue-pro.md | 65 + docs/vue-tiers.md | 172 ++ docs/workflows.md | 207 +++ 93 files changed, 12088 insertions(+), 34 deletions(-) create mode 100644 brain-engine/README.md create mode 100644 brain-engine/distill.py create mode 100644 brain-engine/embed.py create mode 100644 brain-engine/mcp_server.py create mode 100644 brain-engine/migrate.py create mode 100644 brain-engine/queries/cold-start-kpi.sql create mode 100644 brain-engine/queries/graduation-candidates.sql create mode 100644 brain-engine/queries/metabolism-dashboard.sql create mode 100644 brain-engine/queries/stale-claims.sql create mode 100644 brain-engine/rag.py create mode 100644 brain-engine/requirements.txt create mode 100644 brain-engine/schema.sql create mode 100644 brain-engine/search.py create mode 100644 brain-engine/server.py create mode 100755 brain-engine/start.sh create mode 100644 brain-engine/test_brain_engine.py create mode 100755 brain-ui/build.sh create mode 100644 brain-ui/index.html create mode 100644 brain-ui/package.json create mode 120000 brain-ui/public/docs/README.md create mode 120000 brain-ui/public/docs/agents-brain.md create mode 120000 brain-ui/public/docs/agents-code.md create mode 120000 brain-ui/public/docs/agents-infra.md create mode 120000 brain-ui/public/docs/agents.md create mode 120000 brain-ui/public/docs/architecture.md create mode 120000 brain-ui/public/docs/getting-started.md create mode 120000 brain-ui/public/docs/sessions.md create mode 120000 brain-ui/public/docs/vue-featured.md create mode 120000 brain-ui/public/docs/vue-free.md create mode 120000 brain-ui/public/docs/vue-full.md create mode 120000 brain-ui/public/docs/vue-pro.md create mode 120000 brain-ui/public/docs/vue-tiers.md create mode 120000 brain-ui/public/docs/workflows.md create mode 100644 brain-ui/src/App.tsx create mode 100644 brain-ui/src/components/CommandPalette.tsx create mode 100644 brain-ui/src/components/DocsView.tsx create mode 100644 brain-ui/src/components/GateDrawer.tsx create mode 100644 brain-ui/src/components/GatesDrawer.tsx create mode 100644 brain-ui/src/components/InfraRegistry.tsx create mode 100644 brain-ui/src/components/LogDrawer.tsx create mode 100644 brain-ui/src/components/SecretsZone.tsx create mode 100644 brain-ui/src/components/StepNode.tsx create mode 100644 brain-ui/src/components/TeamSelector.tsx create mode 100644 brain-ui/src/components/TierGate.tsx create mode 100644 brain-ui/src/components/ToastProvider.tsx create mode 100644 brain-ui/src/components/WorkflowBoard.tsx create mode 100644 brain-ui/src/components/WorkflowBuilder.tsx create mode 100644 brain-ui/src/components/cosmos/CosmosControls.tsx create mode 100644 brain-ui/src/components/cosmos/CosmosInfoPanel.tsx create mode 100644 brain-ui/src/components/cosmos/CosmosMetrics.tsx create mode 100644 brain-ui/src/components/cosmos/CosmosPoints.tsx create mode 100644 brain-ui/src/components/cosmos/CosmosScene.tsx create mode 100644 brain-ui/src/components/cosmos/CosmosView.tsx create mode 100644 brain-ui/src/components/workspace/CosmosBackground.tsx create mode 100644 brain-ui/src/components/workspace/GateOctahedron.tsx create mode 100644 brain-ui/src/components/workspace/StepSphere.tsx create mode 100644 brain-ui/src/components/workspace/WorkflowConstellation.tsx create mode 100644 brain-ui/src/components/workspace/WorkspaceInfoPanel.tsx create mode 100644 brain-ui/src/components/workspace/WorkspaceMetrics.tsx create mode 100644 brain-ui/src/components/workspace/WorkspaceView.tsx create mode 100644 brain-ui/src/hooks/useCosmosData.ts create mode 100644 brain-ui/src/hooks/useInfra.ts create mode 100644 brain-ui/src/hooks/useLogs.ts create mode 100644 brain-ui/src/hooks/useTeams.ts create mode 100644 brain-ui/src/hooks/useTier.ts create mode 100644 brain-ui/src/hooks/useWebSocket.ts create mode 100644 brain-ui/src/hooks/useWorkflows.ts create mode 100644 brain-ui/src/hooks/useWorkspaceData.ts create mode 100644 brain-ui/src/index.css create mode 100644 brain-ui/src/main.tsx create mode 100644 brain-ui/src/store/brain.store.ts create mode 100644 brain-ui/src/types/index.ts create mode 100644 brain-ui/src/vite-env.d.ts create mode 100644 brain-ui/tailwind.config.js create mode 100644 brain-ui/tsconfig.json create mode 100644 brain-ui/vite.config.ts create mode 100644 docs/README.md create mode 100644 docs/agents-brain.md create mode 100644 docs/agents-code.md create mode 100644 docs/agents-infra.md create mode 100644 docs/agents.md create mode 100644 docs/architecture.md create mode 100644 docs/getting-started.md create mode 100644 docs/sessions.md create mode 100644 docs/vue-featured.md create mode 100644 docs/vue-free.md create mode 100644 docs/vue-full.md create mode 100644 docs/vue-pro.md create mode 100644 docs/vue-tiers.md create mode 100644 docs/workflows.md diff --git a/DISTRIBUTION_CHECKLIST.md b/DISTRIBUTION_CHECKLIST.md index 2fa4950..e1d1607 100644 --- a/DISTRIBUTION_CHECKLIST.md +++ b/DISTRIBUTION_CHECKLIST.md @@ -34,8 +34,11 @@ Attendu : **0 résultats**. ``` brain-template/ 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/ + brain-engine/ ← moteur local (server, embed, search, RAG, MCP) + brain-ui/ ← dashboard React (docs, workflows, cosmos) + docs/ ← guides humains (14 pages) profil/ decisions/ ← ADRs (placeholders domaine) collaboration.md.example @@ -67,28 +70,59 @@ brain-template/ **Exclus** (trop owner-specific) : `session-infra.yml`, `session-deploy.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 -**v1.0 : wiki absent (Option A).** -Le nouvel utilisateur construit son wiki au fil des sessions. -Le wiki se construit naturellement via `wiki-scribe` en session. - -Si un wiki starter est ajouté en v2.0 : auditer chaque fichier avant inclusion. +**v1.0 : wiki absent.** +Le nouvel utilisateur construit son wiki au fil des sessions via `wiki-scribe`. +Le wiki est technique (audience agents) — le docs/ couvre l'onboarding humain. --- ## Checklist avant release - [ ] `grep tetardtek` → 0 résultats -- [ ] `ls contexts/` → 9 fichiers présents +- [ ] `ls contexts/` → 10 fichiers présents - [ ] `ls agent-memory/` → README.md + _template/ - [ ] 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 - [ ] `brain-compose.local.yml.example` → aucun token/credential réel - [ ] 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* diff --git a/brain-compose.local.yml.example b/brain-compose.local.yml.example index 5831186..3bc1177 100644 --- a/brain-compose.local.yml.example +++ b/brain-compose.local.yml.example @@ -3,21 +3,44 @@ # Copier depuis brain-compose.local.yml.example, remplir, NE PAS commiter. kernel_path: -kernel_version: "0.2.0" +kernel_version: "0.9.0" last_kernel_sync: "" +machine: instances: prod: path: brain_name: prod - feature_set: full - config_status: hydrated # hydrated / partial / empty active: true + config_status: empty # empty → partial → hydrated (après brain-setup.sh) + mode: prod - # Exemple — instance client ou template-test : - # template-test: - # path: -test - # brain_name: template-test - # feature_set: full - # config_status: partial - # active: false + # Brain API Key — optionnelle + # Sans clé → tier: free (le brain fonctionne sans restriction sur les fondamentaux) + # Avec clé → tier validé au boot par key-guardian (free / featured / pro / full) + # Obtenir une clé : voir docs/getting-started.md (futur) + brain_api_key: null + + # 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://:7700 +# active: true diff --git a/brain-compose.yml b/brain-compose.yml index 7027eeb..53ad644 100644 --- a/brain-compose.yml +++ b/brain-compose.yml @@ -2,7 +2,7 @@ # Versionné dans le kernel. Schema + feature flags + registre agents. # Géré par l'agent brain-compose — ne pas éditer manuellement. -version: "0.7.0" +version: "0.9.0" # --- # Ownership — kerneluser @@ -11,6 +11,34 @@ version: "0.7.0" # Défaut : true sur tout brain forké (l'owner est toujours kerneluser) # --- 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..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) @@ -166,12 +194,12 @@ modes: contexte: false reference: read personnel: false - brain_write: false # pas d'écriture brain/ — uniquement le repo projet + brain_write: false forge: false - scope_lock: true # BLOQUÉ hors du scope déclaré dans le claim - zone_lock: project # zone:kernel → BLOCKED_ON immédiat, pas de négociation + scope_lock: true + zone_lock: project 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" agents: [code-review, security, testing, debug, vps, ci-cd, pm2, migration] behavior: | @@ -217,19 +245,29 @@ detectmode: mode: coach - bsi_claim: 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 # Les agents "bloqués" existent dans le kernel, brain-compose contrôle l'accès. +# Chaîne : free → featured → pro → full # --- feature_sets: free: 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: - - coach + - coach-boot + - brain-guardian - scribe - todo-scribe - debug @@ -242,11 +280,40 @@ feature_sets: - orchestrator-scribe - recruiter - 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: - description: "Agents métier — développement complet" - extends: free + description: "Agents métier — développement complet + coaching full" + extends: featured + coach_level: full + sessions: + extends: featured + - audit + - deploy + - infra + - urgence + - refacto + - migration agents: + - coach # coach.md full — remplace coach-boot en pro+ - code-review - security - testing @@ -269,10 +336,15 @@ feature_sets: - mail - brain-compose - config-scribe + - audit + - brain-state-bot full: - description: "Accès complet — usage personnel sans restriction" + description: "Accès complet — owner, usage personnel sans restriction + distillation" extends: pro + coach_level: L2 # coach.md + BACT + milestones long terme + sessions: "*" # inclut kernel + edit-brain — owner uniquement + distillation: true agents: "*" # --- @@ -290,19 +362,25 @@ changelog: notes: "BSI (BRAIN-INDEX.md), brain_name, brain-template, aside, brainstorm, brain-compose up" - version: "0.3.0" 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" 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" 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" 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" 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" 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)" diff --git a/brain-engine/README.md b/brain-engine/README.md new file mode 100644 index 0000000..3556fdc --- /dev/null +++ b/brain-engine/README.md @@ -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/ +``` diff --git a/brain-engine/distill.py b/brain-engine/distill.py new file mode 100644 index 0000000..608698b --- /dev/null +++ b/brain-engine/distill.py @@ -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 → distille la session + python3 brain-engine/distill.py --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() diff --git a/brain-engine/embed.py b/brain-engine/embed.py new file mode 100644 index 0000000..6a2ada1 --- /dev/null +++ b/brain-engine/embed.py @@ -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() diff --git a/brain-engine/mcp_server.py b/brain-engine/mcp_server.py new file mode 100644 index 0000000..c2f5935 --- /dev/null +++ b/brain-engine/mcp_server.py @@ -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: +""" + +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) diff --git a/brain-engine/migrate.py b/brain-engine/migrate.py new file mode 100644 index 0000000..4eaf037 --- /dev/null +++ b/brain-engine/migrate.py @@ -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() diff --git a/brain-engine/queries/cold-start-kpi.sql b/brain-engine/queries/cold-start-kpi.sql new file mode 100644 index 0000000..5f6f62a --- /dev/null +++ b/brain-engine/queries/cold-start-kpi.sql @@ -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; diff --git a/brain-engine/queries/graduation-candidates.sql b/brain-engine/queries/graduation-candidates.sql new file mode 100644 index 0000000..493913d --- /dev/null +++ b/brain-engine/queries/graduation-candidates.sql @@ -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; diff --git a/brain-engine/queries/metabolism-dashboard.sql b/brain-engine/queries/metabolism-dashboard.sql new file mode 100644 index 0000000..490ca7a --- /dev/null +++ b/brain-engine/queries/metabolism-dashboard.sql @@ -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'); diff --git a/brain-engine/queries/stale-claims.sql b/brain-engine/queries/stale-claims.sql new file mode 100644 index 0000000..9c15e82 --- /dev/null +++ b/brain-engine/queries/stale-claims.sql @@ -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; diff --git a/brain-engine/rag.py b/brain-engine/rag.py new file mode 100644 index 0000000..3be2966 --- /dev/null +++ b/brain-engine/rag.py @@ -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() diff --git a/brain-engine/requirements.txt b/brain-engine/requirements.txt new file mode 100644 index 0000000..e6e942c --- /dev/null +++ b/brain-engine/requirements.txt @@ -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 diff --git a/brain-engine/schema.sql b/brain-engine/schema.sql new file mode 100644 index 0000000..b9ff609 --- /dev/null +++ b/brain-engine/schema.sql @@ -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/ + 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- + 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/.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/// +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; diff --git a/brain-engine/search.py b/brain-engine/search.py new file mode 100644 index 0000000..862be68 --- /dev/null +++ b/brain-engine/search.py @@ -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() diff --git a/brain-engine/server.py b/brain-engine/server.py new file mode 100644 index 0000000..69e4799 --- /dev/null +++ b/brain-engine/server.py @@ -0,0 +1,1531 @@ +#!/usr/bin/env python3 +""" +brain-engine/server.py — Brain-as-a-Service BE-4 +Expose la recherche sémantique via HTTP (FastAPI + uvicorn). + +Usage : + python3 brain-engine/server.py → port 7700 (défaut) + BRAIN_PORT=8080 python3 brain-engine/server.py → port custom + +Tokens (MYSECRETS) : + BRAIN_TOKEN_OWNER → zones public + work + kernel (toi, sessions locales) + BRAIN_TOKEN_MCP → zones public + work (Claude via MCP) + BRAIN_TOKEN_PUBLIC → zone public seule (bot, démo externe) + BRAIN_TOKEN → alias owner (compat BE-3) + +Zones : + public → focus.md, wiki/, agents/, infrastructure/ + work → todo/, projets/, handoffs/, workspace/ + kernel → profil/, KERNEL.md, contexts/ + (private → jamais indexé — profil/capital.md, objectifs.md...) + +Tier enforcement (has_feature) : + free : /search, /boot, /agents, /teams, /workflows, /workflows/create, /logs, /ws + pro : /visualize, /infra, PUT /brain/{path}, POST /ambient/notify (remote) + owner : tout + POST /gate/{wf}/{step}/approve + +Level 2 localhost trust (_is_localhost) : + BSI endpoints → bypass auth + tier depuis 127.0.0.1 + Pay endpoints (visualize, infra, brain_write) → bypass tier depuis localhost (owner machine) + +Endpoints : + GET /health → statut + uptime + version [aucun] + GET /state → env fondamental dérivé (pm2+git) [L2 only] + GET /boot → zones brain + queries initiales [free] + GET /search?q= → RAG sémantique [free] + GET /agents → liste agents disponibles [free] + GET /teams → liste team presets [free] + GET /workflows → claims ouverts [free] + POST /workflows/create → créer un claim BSI [free] + GET /tier → tier actif + feature_tier map [aucun] + GET /visualize → coordonnées 3D UMAP [PRO] + GET /infra → services pm2 registry [PRO] + PUT /brain/{path} → écriture fichier brain + reindex [PRO/owner] + POST /ambient/notify → broadcast event daemon Ambient [PRO; localhost=free] + POST /gate/{wf}/{step}/approve → approuver un gate workflow [owner] + GET /bsi/claims → liste claims BSI depuis brain.db [free; localhost bypass] + POST /bsi/claims → créer un claim BSI dans brain.db [owner; localhost bypass] + PATCH /bsi/claims/{sess_id} → update claim (status, close, result) [owner; localhost bypass] + GET /bsi/locks → liste locks actifs [free; localhost bypass] + POST /bsi/locks → acquérir un lock fichier [owner; localhost bypass] + DELETE /bsi/locks/{filepath} → libérer un lock fichier [owner; localhost bypass] + GET /bsi/network → vue réseau BSI (peers + claims agrégés) [free; localhost bypass] + GET /logs/{project} → logs projet [free] + WS /ws → WebSocket temps réel [free] +""" + +import os +import sys +import re +import time +import hashlib +import json +import logging +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +import subprocess +import asyncio +from fastapi import FastAPI, Header, HTTPException, Query, Body, WebSocket, Request +from fastapi.responses import JSONResponse +from fastapi.websockets import WebSocketDisconnect + +try: + import yaml + _YAML_AVAILABLE = True +except ImportError: + _YAML_AVAILABLE = False + +# Import moteur RAG depuis le même répertoire +sys.path.insert(0, str(Path(__file__).parent)) +from rag import run_boot_queries, run_single_query + +# ── Config ───────────────────────────────────────────────────────────────────── + +BRAIN_PORT = int(os.getenv('BRAIN_PORT', 7700)) + +# Zones accessibles par tier +_SCOPE_ACCESS: dict[str, list[str]] = { + 'owner': ['public', 'work', 'kernel'], + 'mcp': ['public', 'work'], + 'public': ['public'], +} + +# Résolution token → tier (dernière valeur gagne si conflit) +_TOKEN_MAP: dict[str, str] = {} +for _env, _tier in [ + ('BRAIN_TOKEN', 'owner'), # compat BE-3 — alias owner + ('BRAIN_TOKEN_OWNER', 'owner'), + ('BRAIN_TOKEN_MCP', 'mcp'), + ('BRAIN_TOKEN_PUBLIC', 'public'), +]: + _val = os.getenv(_env) + if _val: + _TOKEN_MAP[_val] = _tier + +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') +log = logging.getLogger('brain-engine') + +FEATURE_TIER: dict[str, str] = { + 'visualize': 'pro', + 'distillation': 'pro', + 'ambient': 'pro', + 'brain_write': 'pro', + 'infra': 'pro', + 'search': 'free', + 'progression': 'free', + 'boot': 'free', + 'workflows': 'free', + 'gate_approve': 'free', + 'logs': 'free', + 'bsi': 'free', +} + +TIER_RANK = {'free': 0, 'pro': 1, 'owner': 2, 'full': 2} # 'full' = alias owner + +# ── Tier cache ────────────────────────────────────────────────────────────────── + +KEYS_API = os.getenv('BRAIN_KEYS_API', '') # URL key server — vide = tier free par défaut +TIER_TTL = 3600 # 1h TTL normal +TIER_GRACE = 7 * 86400 # 7 jours grace offline + +# { token_hash: (tier, expires_at) } +_tier_cache: dict[str, tuple[str, float]] = {} + + +def has_feature(feature: str, tier: str) -> bool: + required = FEATURE_TIER.get(feature, 'owner') + return TIER_RANK.get(tier, 0) >= TIER_RANK.get(required, 99) + + +def get_tier_from_request(authorization: str | None) -> str: + """ + Résout le tier depuis le header Authorization. + 1. BRAIN_TIER env → override dev/local immédiat + 2. Pas de token → 'free' + 3. Cache valide (< 1h) → retour immédiat + 4. Validation réseau contre {KEYS_API}/validate (POST, timeout 3s) + - 200 {"tier": ...} → cache TTL 1h + - réseau down → grace cache 7j ou 'free' + - 401/403 → 'free' + """ + # 1. Override env + env_tier = os.getenv('BRAIN_TIER') + if env_tier: + return env_tier + + # 2. Extraire le token + if not authorization or not authorization.startswith('Bearer '): + return 'free' + token = authorization.removeprefix('Bearer ').strip() + if not token: + return 'free' + + # 3. Hash token + token_hash = hashlib.sha256(token.encode()).hexdigest()[:16] + now = time.time() + + # 4. Cache valide ? + if token_hash in _tier_cache: + cached_tier, expires_at = _tier_cache[token_hash] + if expires_at > now: + return cached_tier + + # 5. Validation réseau + try: + payload = json.dumps({'key': token}).encode() + req = urllib.request.Request( + f'{KEYS_API}/validate', + data=payload, + headers={'Content-Type': 'application/json'}, + method='POST', + ) + with urllib.request.urlopen(req, timeout=3) as resp: + if resp.status == 200: + data = json.loads(resp.read().decode()) + tier = data.get('tier', 'free') + if tier == 'full': + tier = 'owner' # normalise alias + if tier not in TIER_RANK: + tier = 'free' + _tier_cache[token_hash] = (tier, now + TIER_TTL) + return tier + else: + # 401/403 → token invalide + return 'free' + except Exception as exc: + log.warning('get_tier_from_request: network error (%s) — trying grace cache', exc) + # Grace offline : accepter un cache expiré jusqu'à 7j + if token_hash in _tier_cache: + cached_tier, expires_at = _tier_cache[token_hash] + if now - expires_at < TIER_GRACE: + return cached_tier + return 'free' + +# Uptime tracking +_START_TIME: float = time.time() + +# WebSocket clients +_ws_clients: list[WebSocket] = [] + +# Racine du brain (un niveau au-dessus de brain-engine/) +BRAIN_ROOT = Path(__file__).parent.parent + +app = FastAPI(title='Brain-as-a-Service', version='BE-4', docs_url='/docs') + +# ── Montage brain-ui static (si build disponible) ──────────────────────────── + +_UI_DIST = BRAIN_ROOT / 'brain-ui' / 'dist' +if _UI_DIST.is_dir(): + from fastapi.staticfiles import StaticFiles + app.mount('/ui', StaticFiles(directory=str(_UI_DIST), html=True), name='brain-ui') + log.info('brain-ui monté sur /ui depuis %s', _UI_DIST) + + +# ── Level 2 — localhost frictionless ─────────────────────────────────────────── + +def _is_localhost(request: Request) -> bool: + """True si la requête vient de localhost — Level 2 agents (frictionless). + Si X-Forwarded-For présent → vient d'Apache proxy → pas localhost trust. + """ + if request is None: + return False + if request.headers.get('x-forwarded-for'): + return False + client_host = request.client.host if request.client else '' + result = client_host in ('127.0.0.1', '::1', 'localhost') + if result: + log.debug('level2 local bypass: %s', request.url.path) + return result + + +# ── Auth ─────────────────────────────────────────────────────────────────────── + +def check_auth(authorization: str | None) -> list[str]: + """ + Vérifie le header Authorization: Bearer . + Retourne la liste des scopes autorisés pour ce token. + Si aucun token configuré : auth désactivée (dev local) → accès total. + """ + if not _TOKEN_MAP: + return ['public', 'work', 'kernel'] # dev local — accès total + if not authorization or not authorization.startswith('Bearer '): + raise HTTPException(status_code=401, detail='Authorization header requis') + token = authorization.removeprefix('Bearer ').strip() + tier = _TOKEN_MAP.get(token) + if not tier: + raise HTTPException(status_code=403, detail='Token invalide') + return _SCOPE_ACCESS[tier] + + +# ── Routes ───────────────────────────────────────────────────────────────────── + +@app.get('/health') +def health(): + """Sanity check — vérifie que le moteur répond.""" + uptime = int(time.time() - _START_TIME) + try: + import sqlite3 + from search import DB_PATH + conn = sqlite3.connect(DB_PATH) + count = conn.execute("SELECT COUNT(*) FROM embeddings WHERE indexed=1").fetchone()[0] + conn.close() + return {'status': 'ok', 'indexed': count, 'uptime': uptime} + except Exception as e: + return JSONResponse(status_code=503, content={'status': 'error', 'detail': str(e), 'uptime': uptime}) + + +@app.get('/search') +def search( + q: str = Query(..., description='Requête en langage naturel'), + top: int = Query(5, description='Nombre de résultats'), + full: bool = Query(False, description='Chunks complets (défaut: compact)'), + mode: str = Query('develop', description='develop | service (réservé)'), + authorization: str | None = Header(None), +): + scopes = check_auth(authorization) + log.info('search q=%r top=%d full=%s scopes=%s', q, top, full, scopes) + + results = run_single_query(q, top_k=top, allowed_scopes=scopes) + + return _format_results(results, full=full, mode=mode) + + +@app.get('/boot') +def boot( + full: bool = Query(False, description='Chunks complets (défaut: compact)'), + mode: str = Query('develop', description='develop | service (réservé)'), + authorization: str | None = Header(None), + request: Request = None, +): + if _is_localhost(request): + scopes = ['public', 'work', 'kernel'] + else: + scopes = check_auth(authorization) + log.info('boot full=%s scopes=%s', full, scopes) + + results = run_boot_queries(allowed_scopes=scopes) + + return _format_results(results, full=full, mode=mode) + + +def _load_catalog(agents_dir: Path) -> dict: + """ + Charge agents/CATALOG.yml et retourne un dict {agent_id: {tier, export, description}}. + Retourne {} si CATALOG absent ou invalide. + """ + catalog_path = agents_dir / 'CATALOG.yml' + if not catalog_path.exists(): + return {} + data = _load_yaml_file(catalog_path) + if not data or not isinstance(data.get('agents'), list): + return {} + return { + entry['id']: { + 'tier': entry.get('tier', 'free'), + 'export': entry.get('export', True), + 'description': entry.get('description', ''), + } + for entry in data['agents'] + if isinstance(entry, dict) and 'id' in entry + } + + +# Tier access hierarchy: owner sees all, pro sees pro+free, free sees only free +_CATALOG_TIER_RANK: dict[str, int] = {'free': 0, 'pro': 1, 'owner': 2} + +# Map token tier → max catalog tier accessible +_TOKEN_TIER_TO_CATALOG: dict[str, str] = { + 'free': 'free', + 'mcp': 'pro', + 'pro': 'pro', + 'owner': 'owner', +} + + +def _catalog_tier_allowed(agent_catalog_tier: str, request_tier: str) -> bool: + """True si l'agent est accessible au tier de la requête.""" + catalog_rank = _CATALOG_TIER_RANK.get(agent_catalog_tier, 99) + max_tier = _TOKEN_TIER_TO_CATALOG.get(request_tier, 'free') + allowed_rank = _CATALOG_TIER_RANK.get(max_tier, 0) + return catalog_rank <= allowed_rank + + +@app.get('/agents') +def agents_list( + authorization: str | None = Header(None), + request: Request = None, +): + """Liste les agents disponibles, filtrés par tier depuis agents/CATALOG.yml.""" + if not _is_localhost(request): + check_auth(authorization) # zones=['public'] — tout token valide suffit + + # Résoudre le tier de l'appelant + if _is_localhost(request): + req_tier = 'owner' + else: + req_tier = get_tier_from_request(authorization) + + log.info('agents_list tier=%s', req_tier) + + agents_dir = BRAIN_ROOT / 'agents' + tier_map = _parse_agents_tier_map(agents_dir / 'AGENTS.md') + catalog = _load_catalog(agents_dir) + result = [] + + for md_file in sorted(agents_dir.glob('*.md')): + if md_file.name in ('AGENTS.md', '_template.md', '_template-orchestrator.md'): + continue + fm = _parse_frontmatter(md_file) + if not fm: + continue + agent_id = fm.get('name') or md_file.stem + + # Filtrage par tier depuis CATALOG — si CATALOG absent, tout passe (comportement legacy) + if catalog: + cat_entry = catalog.get(agent_id) + if cat_entry is None: + # Agent absent du CATALOG → visible uniquement pour owner + if req_tier != 'owner': + continue + catalog_tier = 'owner' + export = False + else: + catalog_tier = cat_entry['tier'] + export = cat_entry['export'] + if not _catalog_tier_allowed(catalog_tier, req_tier): + continue + else: + catalog_tier = 'free' + export = True + + info = tier_map.get(agent_id, {}) + brain = fm.get('brain', {}) if isinstance(fm.get('brain'), dict) else {} + result.append({ + 'id': agent_id, + 'label': agent_id, + 'tier': catalog_tier, + 'export': export, + 'status': fm.get('status', 'active'), + 'triggers': brain.get('triggers') or fm.get('domain') or [], + 'scope': brain.get('scope', 'project'), + 'created': info.get('created', ''), + 'description': catalog.get(agent_id, {}).get('description', ''), + }) + + return result + + +@app.get('/teams') +def teams_list( + authorization: str | None = Header(None), + request: Request = None, +): + """Liste toutes les teams parsées depuis teams/*.yml.""" + if not _is_localhost(request): + check_auth(authorization) # zones=['public'] + log.info('teams_list') + + teams_dir = BRAIN_ROOT / 'teams' + result = [] + + for yml_file in sorted(teams_dir.glob('*.yml')): + data = _load_yaml_file(yml_file) + if not data: + continue + result.append({ + 'id': data.get('id', yml_file.stem), + 'label': data.get('label', ''), + 'icon': data.get('icon', ''), + 'agents': data.get('agents', []), + 'capabilities': data.get('capabilities', []), + 'gate_required': data.get('gate_required', False), + 'default_timeout_min': data.get('default_timeout_min', 30), + }) + + return result + + +@app.get('/workflows') +def workflows_list( + authorization: str | None = Header(None), + request: Request = None, +): + """Retourne les workflows BSI depuis brain.db (ADR-042).""" + if _is_localhost(request): + scopes = ['work', 'kernel', 'public'] + else: + scopes = check_auth(authorization) + if 'work' not in scopes: + raise HTTPException(status_code=403, detail='Zone work requise') + log.info('workflows_list scopes=%s', scopes) + + db_path = BRAIN_ROOT / 'brain.db' + if not db_path.exists(): + return [] + + import sqlite3 as _sql + conn = _sql.connect(str(db_path)) + conn.row_factory = _sql.Row + rows = conn.execute( + "SELECT * FROM claims WHERE satellite_type IS NOT NULL OR workflow IS NOT NULL " + "ORDER BY opened_at DESC" + ).fetchall() + conn.close() + + result = [] + for r in rows: + result.append({ + 'id': r['sess_id'], + 'name': r['story_angle'] or r['workflow'] or r['sess_id'], + 'project': r['workflow'] or r['scope'] or r['sess_id'], + 'status': r['status'] or 'open', + 'opened_at': r['opened_at'] or '', + 'workflow_step': r['workflow_step'], + 'satellite_type': r['satellite_type'], + 'steps': [], + }) + + return result + + +@app.post('/workflows/create') +def workflows_create( + body: dict = Body(...), + authorization: str | None = Header(None), + request: Request = None, +): + """Crée un claim BSI dans brain.db (ADR-042). Requiert zone kernel (owner uniquement).""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + + title = body.get('title', '') + team_id = body.get('teamId', '') + + if not title: + raise HTTPException(status_code=422, detail='title requis') + + now = datetime.now(timezone.utc) + date_str = now.strftime('%Y%m%d-%H%M') + slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')[:40] + sess_id = f'sess-{date_str}-{slug}' + now_str = now.strftime('%Y-%m-%dT%H:%M') + + db_path = BRAIN_ROOT / 'brain.db' + import sqlite3 as _sql + conn = _sql.connect(str(db_path)) + conn.execute( + "INSERT OR REPLACE INTO claims " + "(sess_id, type, scope, status, opened_at, story_angle, workflow, zone, mode, " + " handoff_level, ttl_hours, expires_at) " + "VALUES (?, 'work', ?, 'open', ?, ?, ?, 'project', ?, '0', 4.0, datetime(?, '+4 hours'))", + (sess_id, f'work/{slug}', now_str, title, title, team_id or 'build', now_str) + ) + conn.commit() + conn.close() + log.info('workflows_create sess_id=%s (brain.db)', sess_id) + + return {'ok': True, 'claimId': sess_id} + + +@app.get('/visualize') +def visualize( + request: Request, + zone: str = Query('all'), + force: bool = Query(False), + authorization: str | None = Header(None), +): + """Retourne les coordonnées 3D UMAP des embeddings brain. Cache JSON regénéré si stale.""" + check_auth(authorization) + if not _is_localhost(request): + tier = get_tier_from_request(authorization) + if not has_feature('visualize', tier): + raise HTTPException(status_code=403, detail='feature:visualize requires pro tier') + + cache_path = BRAIN_ROOT / 'brain-engine' / 'viz_cache.json' + db_path = BRAIN_ROOT / 'brain.db' + + need_regen = force or not cache_path.exists() + if not need_regen and cache_path.exists() and db_path.exists(): + need_regen = db_path.stat().st_mtime > cache_path.stat().st_mtime + + if need_regen: + try: + import struct as _struct + import numpy as _np + import umap as _umap + + _sqlite3 = __import__('sqlite3') + conn = _sqlite3.connect(str(db_path)) + cur = conn.cursor() + tables = cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='embeddings'" + ).fetchone() + if not tables: + conn.close() + raise HTTPException(status_code=503, detail='embeddings not indexed — run migrate.py') + cur.execute( + 'SELECT filepath, title, scope, vector, chunk_text FROM embeddings' + ' WHERE indexed=1 AND vector IS NOT NULL' + ) + rows = cur.fetchall() + conn.close() + + vecs = [_struct.unpack(f'{len(r[3])//4}f', r[3]) for r in rows] + X = _np.array(vecs, dtype=_np.float32) + + t0 = __import__('time').time() + reducer = _umap.UMAP(n_components=3, n_neighbors=15, min_dist=0.1, random_state=42, verbose=False) + coords = reducer.fit_transform(X) + elapsed = __import__('time').time() - t0 + + points = [ + { + 'id': r[0], + 'path': r[0], + 'zone': r[2] or 'unknown', + 'label': r[1] or Path(r[0]).stem, + 'excerpt': (r[4] or '')[:200], + 'x': float(coords[i, 0]), + 'y': float(coords[i, 1]), + 'z': float(coords[i, 2]), + } + for i, r in enumerate(rows) + ] + cache_data = { + 'points': points, + 'generated_at': datetime.now(timezone.utc).isoformat(), + 'cached': False, + 'umap_params': {'n_components': 3, 'n_neighbors': 15, 'min_dist': 0.1}, + 'elapsed_s': round(elapsed, 1), + } + cache_path.write_text(__import__('json').dumps(cache_data)) + log.info('visualize: cache regenerated %d points in %.1fs', len(points), elapsed) + except Exception as exc: + log.error('visualize regen failed: %s', exc) + if not cache_path.exists(): + raise HTTPException(status_code=503, detail=f'UMAP generation failed: {exc}') + + raw = __import__('json').loads(cache_path.read_text()) + points = raw.get('points', []) + if zone != 'all': + points = [p for p in points if p.get('zone') == zone] + + return {**raw, 'points': points, 'cached': True} + + +@app.get('/tier') +def tier_get(authorization: str | None = Header(None)): + """Retourne le tier actif (owner | pro | free) + features. Cache 1h, grace 7j offline.""" + # Pas d'auth requise — le tier est public (il détermine ce qu'on peut voir) + tier = get_tier_from_request(authorization) + features: dict[str, list[str]] = { + 'owner': ['cosmos', 'workspace', 'workflows', 'builder', 'secrets', 'infra', 'editor'], + 'pro': ['cosmos', 'workspace', 'workflows', 'builder'], + 'free': ['cosmos'], + } + return { + 'tier': tier, + 'features': features.get(tier, features['free']), + 'kernel_access': tier == 'owner', + 'feature_tier': FEATURE_TIER, + } + + +@app.get('/state') +def state_get(request: Request = None): + """ + Environnement fondamental dérivé — Layer 2 uniquement. + pm2 status + git version + ports. Jamais mis en cache, toujours frais. + """ + if not _is_localhost(request): + raise HTTPException(status_code=403, detail='Layer 2 only — localhost requis') + + # pm2 status + pm2_procs = [] + try: + result = subprocess.run(['pm2', 'jlist'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + for proc in json.loads(result.stdout): + env = proc.get('pm2_env', {}) + pm2_procs.append({ + 'name': proc.get('name', '?'), + 'status': env.get('status', 'unknown'), + 'uptime': env.get('pm_uptime'), + 'restarts': env.get('restart_time', 0), + }) + except Exception as exc: + log.warning('state pm2 error: %s', exc) + + # Version brain (dernier commit) + brain_version = '' + try: + r = subprocess.run( + ['git', 'log', '-1', '--oneline'], + capture_output=True, text=True, timeout=3, cwd=str(BRAIN_ROOT) + ) + if r.returncode == 0: + brain_version = r.stdout.strip() + except Exception: + pass + + import socket + return { + 'hostname': socket.gethostname(), + 'brain_version': brain_version, + 'pm2': pm2_procs, + 'ports': { + 'brain_engine': BRAIN_PORT, + 'brain_mcp': int(os.getenv('BRAIN_MCP_PORT', 7701)), + 'brain_key': int(os.getenv('BRAIN_KEY_PORT', 7432)), + }, + } + + +@app.get('/infra') +def infra_list(request: Request, authorization: str | None = Header(None)): + """Retourne l'état des services infrastructure depuis pm2 + config statique.""" + check_auth(authorization) + if not _is_localhost(request): + tier = get_tier_from_request(authorization) + if not has_feature('infra', tier): + raise HTTPException(status_code=403, detail='feature:infra requires pro tier') + log.info('infra_list') + + services = [] + + # Services pm2 + try: + result = subprocess.run( + ['pm2', 'jlist'], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + import json as _json + pm2_list = _json.loads(result.stdout) + for proc in pm2_list: + env = proc.get('pm2_env', {}) + services.append({ + 'id': f"pm2-{proc.get('name', proc.get('pm_id', '?'))}", + 'name': proc.get('name', '?'), + 'type': 'pm2', + 'status': env.get('status', 'unknown'), + 'port': env.get('PORT') or env.get('port') or None, + 'uptime': env.get('pm_uptime', None), + 'restarts': proc.get('pm2_env', {}).get('restart_time', 0), + 'memory': proc.get('monit', {}).get('memory', 0), + 'cpu': proc.get('monit', {}).get('cpu', 0), + }) + except Exception as exc: + log.warning('infra pm2 error: %s', exc) + + # Services statiques (Apache vhosts connus) + static_services = [ + {'id': 'apache', 'name': 'Apache2', 'type': 'system', 'status': 'online', 'port': 443}, + {'id': 'brain-engine','name': 'brain-engine', 'type': 'info', 'status': 'online', 'port': 7700}, + {'id': 'gitea', 'name': 'Gitea', 'type': 'info', 'status': 'online', 'port': 3000}, + ] + + return {'services': services + static_services, 'total': len(services) + len(static_services)} + + +@app.put('/brain/{path:path}') +async def brain_put( + request: Request, + path: str, + body: dict = Body(...), + authorization: str | None = Header(None), +): + """ + Écrit ou met à jour un document brain. + Requiert zone kernel (owner uniquement). + body: { content: str } — contenu Markdown brut + """ + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + if not _is_localhost(request): + tier = get_tier_from_request(authorization) + if not has_feature('brain_write', tier): + raise HTTPException(status_code=403, detail='feature:brain_write requires pro tier') + + # Sécurité : interdire les path traversal + target = (BRAIN_ROOT / path).resolve() + if not str(target).startswith(str(BRAIN_ROOT.resolve())): + raise HTTPException(status_code=400, detail='Path traversal interdit') + + content = body.get('content', '') + if not content: + raise HTTPException(status_code=422, detail='content requis') + + # Écriture + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding='utf-8') + log.info('brain_put path=%s (%d bytes)', path, len(content)) + + # Signal reindex via subprocess (non-bloquant) + try: + subprocess.Popen( + ['python3', str(BRAIN_ROOT / 'brain-engine' / 'index.py'), '--file', str(target)], + cwd=str(BRAIN_ROOT), + ) + reindex_triggered = True + except Exception as exc: + log.warning('brain_put reindex failed: %s', exc) + reindex_triggered = False + + # Broadcast WebSocket — les clients rechargent le point modifié + await _broadcast({ + 'type': 'brain:updated', + 'payload': {'path': path, 'reindex': reindex_triggered}, + }) + + return {'ok': True, 'path': path, 'reindex': reindex_triggered} + + +# ── Ambient Brain ────────────────────────────────────────────────────────────── + +@app.post('/ambient/notify') +async def ambient_notify( + body: dict = Body(...), + authorization: str | None = Header(None), + request: Request = None, +): + """Reçoit un event du daemon Ambient Brain et le broadcast aux clients WebSocket.""" + if _is_localhost(request): + pass # daemon local — toujours OK + else: + tier = get_tier_from_request(authorization) + if not has_feature('ambient', tier): + raise HTTPException(status_code=403, detail='feature:ambient requires pro tier') + event = { + 'type': body.get('type', 'ambient:event'), + 'context': body.get('context', ''), + 'message': body.get('message', ''), + 'level': body.get('level', 'info'), + 'ts': body.get('ts', ''), + } + log.info('ambient_notify context=%s msg=%s', event['context'], event['message']) + await _broadcast(event) + return {'ok': True} + + +# ── WebSocket ────────────────────────────────────────────────────────────────── + +@app.websocket('/ws') +async def websocket_endpoint(websocket: WebSocket): + """WebSocket temps réel — broadcasts workflow:update, gate:pending, gate:resolved.""" + await websocket.accept() + _ws_clients.append(websocket) + try: + while True: + await websocket.receive_text() # keepalive ping + except WebSocketDisconnect: + _ws_clients.remove(websocket) + + +async def _broadcast(payload: dict) -> None: + """Broadcast JSON à tous les clients WebSocket connectés.""" + import json as _json + dead = [] + for ws in list(_ws_clients): + try: + await ws.send_text(_json.dumps(payload)) + except Exception: + dead.append(ws) + for ws in dead: + if ws in _ws_clients: + _ws_clients.remove(ws) + + +# ── GET /logs/{project} ───────────────────────────────────────────────────────── + +_LOG_LINE_RE = re.compile( + r'(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[.\d]*Z?)?' + r'\s*(?PERROR|WARN(?:ING)?|INFO|DEBUG)?\s*(?P.+)', + re.IGNORECASE, +) + +def _parse_log_line(raw: str) -> dict | None: + """Parse une ligne pm2 brute en {ts, level, msg}.""" + raw = raw.strip() + if not raw or raw.startswith('> Log tailing'): + return None + + # Essai extraction ts ISO + ts_now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + + m = _LOG_LINE_RE.match(raw) + if not m: + return {'ts': ts_now, 'level': 'info', 'msg': raw} + + ts = m.group('ts') or ts_now + raw_level = (m.group('level') or 'info').lower() + level = 'warn' if raw_level.startswith('warn') else raw_level if raw_level in ('error', 'debug') else 'info' + msg = m.group('msg').strip() or raw + + return {'ts': ts, 'level': level, 'msg': msg} + + +@app.get('/logs/{project}') +def logs_get( + project: str, + since: str | None = Query(None, description='ISO8601 — exclure les lignes antérieures'), + authorization: str | None = Header(None), +): + """Lit les 50 dernières lignes pm2 pour un projet. Requiert zone work.""" + scopes = check_auth(authorization) + if 'work' not in scopes: + raise HTTPException(status_code=403, detail='Zone work requise') + + log.info('logs_get project=%s since=%s', project, since) + + try: + result = subprocess.run( + ['pm2', 'logs', project, '--lines', '50', '--nostream'], + capture_output=True, text=True, timeout=10, + ) + raw_lines = (result.stdout + result.stderr).splitlines() + except FileNotFoundError: + raw_lines = [f'[mock] pm2 non disponible — project={project}'] + except subprocess.TimeoutExpired: + raw_lines = ['[error] pm2 timeout'] + + lines = [_parse_log_line(l) for l in raw_lines] + lines = [l for l in lines if l is not None] + + if since: + lines = [l for l in lines if l['ts'] > since] + + return {'lines': lines} + + +# ── POST /gate/{workflow_id}/{step_id}/approve ────────────────────────────────── + +@app.post('/gate/{workflow_id}/{step_id}/approve') +async def gate_approve( + workflow_id: str, + step_id: str, + body: dict = Body(...), + authorization: str | None = Header(None), +): + """Résout une gate (approve / abort / skip). Requiert zone kernel (owner).""" + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + + action = body.get('action', 'approve') + if action not in ('approve', 'abort', 'skip'): + raise HTTPException(status_code=422, detail='action doit être approve | abort | skip') + + now = datetime.now(timezone.utc) + resolved_at = now.strftime('%Y-%m-%dT%H:%M:%SZ') + ack = { + 'workflow_id': workflow_id, + 'step_id': step_id, + 'action': action, + 'resolved_at': resolved_at, + } + + # Écriture du fichier gate-ack YAML + claims_dir = BRAIN_ROOT / 'claims' + claims_dir.mkdir(parents=True, exist_ok=True) + slug = re.sub(r'[^a-z0-9]+', '-', f'{workflow_id}-{step_id}'.lower()).strip('-') + ack_path = claims_dir / f'gate-ack-{slug}.yml' + _write_yaml_file(ack_path, ack) + + log.info('gate_approve workflow=%s step=%s action=%s', workflow_id, step_id, action) + + # Broadcast WebSocket + await _broadcast({ + 'type': 'gate:resolved', + 'payload': {'workflowId': workflow_id, 'stepId': step_id, 'result': action}, + }) + + return {'ok': True} + + +# ── BSI endpoints (ADR-036) ──────────────────────────────────────────────── + +import sqlite3 + +DB_BSI_PATH = str(BRAIN_ROOT / 'brain.db') + +# ── BSI peers — chargement depuis brain-compose.local.yml ───────────────── + +def _load_peers() -> list[dict]: + """Charge les peers actifs depuis brain-compose.local.yml.""" + compose_local = BRAIN_ROOT / 'brain-compose.local.yml' + if not compose_local.exists(): + return [] + try: + if _YAML_AVAILABLE: + with open(compose_local) as f: + data = yaml.safe_load(f) or {} + else: + return [] + peers = data.get('peers', {}) + return [ + {'name': name, 'url': p.get('url', '')} + for name, p in peers.items() + if isinstance(p, dict) and p.get('active', False) + ] + except Exception as exc: + log.warning('peers load error: %s', exc) + return [] + + +def _fetch_peer_claims(peer_url: str, timeout: float = 2.0) -> list[dict]: + """Fetch claims depuis un peer brain-engine. Timeout court — best effort.""" + try: + req = urllib.request.Request(f"{peer_url.rstrip('/')}/bsi/claims") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + except Exception as exc: + log.debug('peer %s unreachable: %s', peer_url, exc) + return [] + +def _bsi_conn() -> sqlite3.Connection: + """Connexion brain.db avec row_factory dict — init schema si absent.""" + conn = sqlite3.connect(DB_BSI_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + # Ensure BSI tables exist + conn.executescript(""" + CREATE TABLE IF NOT EXISTS locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filepath TEXT NOT NULL UNIQUE, + holder TEXT NOT NULL, + claimed_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + ttl_min INTEGER NOT NULL DEFAULT 60 + ); + CREATE TABLE IF NOT EXISTS circuit_breaker ( + sess_id TEXT PRIMARY KEY, + fail_count INTEGER NOT NULL DEFAULT 0, + last_fail_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + return conn + + +@app.get('/bsi/claims') +def bsi_claims_list( + status: str | None = Query(None), + include_peers: bool = Query(False), + request: Request = None, + authorization: str | None = Header(None), +): + """Liste les claims BSI depuis brain.db. ?include_peers=true agrège les peers.""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'work' not in scopes: + raise HTTPException(status_code=403, detail='Zone work requise') + + # Local claims + conn = _bsi_conn() + try: + if status: + rows = conn.execute( + "SELECT * FROM claims WHERE status = ? ORDER BY opened_at DESC", (status,) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM claims ORDER BY opened_at DESC" + ).fetchall() + local_claims = [dict(r) for r in rows] + finally: + conn.close() + + # Tag local claims with instance + compose_local = BRAIN_ROOT / 'brain-compose.local.yml' + machine_name = 'local' + if compose_local.exists() and _YAML_AVAILABLE: + try: + with open(compose_local) as f: + data = yaml.safe_load(f) or {} + machine_name = data.get('machine', 'local') + except Exception: + pass + + for c in local_claims: + c['_source'] = machine_name + + if not include_peers: + return local_claims + + # Fetch peer claims + all_claims = list(local_claims) + for peer in _load_peers(): + peer_claims = _fetch_peer_claims(peer['url']) + for c in peer_claims: + c['_source'] = peer['name'] + if status and c.get('status') != status: + continue + all_claims.append(c) + + return all_claims + + +@app.get('/bsi/network') +def bsi_network( + request: Request = None, + authorization: str | None = Header(None), +): + """Vue réseau BSI — état de chaque peer + claims open agrégés.""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'work' not in scopes: + raise HTTPException(status_code=403, detail='Zone work requise') + + # Local + conn = _bsi_conn() + try: + local_open = conn.execute( + "SELECT COUNT(*) FROM claims WHERE status = 'open'" + ).fetchone()[0] + local_total = conn.execute("SELECT COUNT(*) FROM claims").fetchone()[0] + finally: + conn.close() + + compose_local = BRAIN_ROOT / 'brain-compose.local.yml' + machine_name = 'local' + if compose_local.exists() and _YAML_AVAILABLE: + try: + with open(compose_local) as f: + data = yaml.safe_load(f) or {} + machine_name = data.get('machine', 'local') + except Exception: + pass + + nodes = [{ + 'name': machine_name, + 'url': f'http://localhost:{BRAIN_PORT}', + 'status': 'online', + 'claims_open': local_open, + 'claims_total': local_total, + }] + + # Peers + for peer in _load_peers(): + peer_claims = _fetch_peer_claims(peer['url']) + if peer_claims is not None and isinstance(peer_claims, list): + open_count = sum(1 for c in peer_claims if c.get('status') == 'open') + nodes.append({ + 'name': peer['name'], + 'url': peer['url'], + 'status': 'online', + 'claims_open': open_count, + 'claims_total': len(peer_claims), + }) + else: + nodes.append({ + 'name': peer['name'], + 'url': peer['url'], + 'status': 'offline', + 'claims_open': 0, + 'claims_total': 0, + }) + + return {'nodes': nodes, 'peer_count': len(nodes)} + + +@app.post('/bsi/claims') +async def bsi_claims_create( + body: dict = Body(...), + request: Request = None, + authorization: str | None = Header(None), +): + """Crée un claim BSI dans brain.db.""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + + sess_id = body.get('sess_id') + if not sess_id: + raise HTTPException(status_code=422, detail='sess_id requis') + + now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + ttl_hours = body.get('ttl_hours', 4) + + conn = _bsi_conn() + try: + conn.execute(""" + INSERT OR REPLACE INTO claims + (sess_id, type, scope, status, opened_at, handoff_level, + ttl_hours, expires_at, instance, parent_sess, + satellite_type, satellite_level, theme_branch, zone, mode) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime(?, '+' || ? || ' hours'), ?, ?, ?, ?, ?, ?, ?) + """, ( + sess_id, + body.get('type', 'work'), + body.get('scope', ''), + body.get('status', 'open'), + body.get('opened_at', now), + body.get('handoff_level'), + ttl_hours, + body.get('opened_at', now), ttl_hours, + body.get('instance'), + body.get('parent_sess'), + body.get('satellite_type'), + body.get('satellite_level'), + body.get('theme_branch'), + body.get('zone'), + body.get('mode'), + )) + conn.commit() + log.info('bsi_claims_create sess_id=%s', sess_id) + + await _broadcast({ + 'type': 'bsi:claim:open', + 'payload': {'sess_id': sess_id, 'scope': body.get('scope', ''), 'status': 'open'}, + }) + + return {'ok': True, 'sess_id': sess_id} + finally: + conn.close() + + +@app.patch('/bsi/claims/{sess_id}') +async def bsi_claims_update( + sess_id: str, + body: dict = Body(...), + request: Request = None, + authorization: str | None = Header(None), +): + """Met à jour un claim BSI (status, result, close).""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + + conn = _bsi_conn() + try: + existing = conn.execute( + "SELECT sess_id FROM claims WHERE sess_id = ?", (sess_id,) + ).fetchone() + if not existing: + raise HTTPException(status_code=404, detail=f'Claim {sess_id} introuvable') + + updates = [] + values = [] + for field in ('status', 'closed_at', 'health_score', 'context_at_close', + 'result_status', 'result_json', 'mode'): + if field in body: + updates.append(f"{field} = ?") + values.append(body[field]) + + if not updates: + raise HTTPException(status_code=422, detail='Aucun champ à mettre à jour') + + # Auto-set closed_at if status → closed + if body.get('status') == 'closed' and 'closed_at' not in body: + updates.append("closed_at = ?") + values.append(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) + + values.append(sess_id) + conn.execute(f"UPDATE claims SET {', '.join(updates)} WHERE sess_id = ?", values) + conn.commit() + log.info('bsi_claims_update sess_id=%s fields=%s', sess_id, list(body.keys())) + + await _broadcast({ + 'type': f'bsi:claim:{body.get("status", "update")}', + 'payload': {'sess_id': sess_id, **body}, + }) + + return {'ok': True, 'sess_id': sess_id} + finally: + conn.close() + + +@app.get('/bsi/locks') +def bsi_locks_list( + request: Request = None, + authorization: str | None = Header(None), +): + """Liste les locks actifs depuis brain.db.""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'work' not in scopes: + raise HTTPException(status_code=403, detail='Zone work requise') + + conn = _bsi_conn() + try: + rows = conn.execute(""" + 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 + """).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +@app.post('/bsi/locks') +async def bsi_locks_acquire( + body: dict = Body(...), + request: Request = None, + authorization: str | None = Header(None), +): + """Acquiert un lock fichier. Échoue si déjà tenu par un autre holder.""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + + filepath = body.get('filepath') + holder = body.get('holder') + ttl_min = body.get('ttl_min', 60) + + if not filepath or not holder: + raise HTTPException(status_code=422, detail='filepath et holder requis') + + now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + + # Check peer locks FIRST (cross-machine coordination) + for peer in _load_peers(): + try: + req = urllib.request.Request(f"{peer['url'].rstrip('/')}/bsi/locks") + with urllib.request.urlopen(req, timeout=2) as resp: + peer_locks = json.loads(resp.read()) + for pl in peer_locks: + if (pl.get('filepath') == filepath + and pl.get('lock_status') == 'active' + and pl.get('holder') != holder): + raise HTTPException( + status_code=409, + detail=f"Lock détenu par {pl['holder']} sur {peer['name']} jusqu'à {pl.get('expires_at')}" + ) + except HTTPException: + raise + except Exception: + pass # peer unreachable — continue (mode dégradé) + + conn = _bsi_conn() + try: + # Check existing local lock + existing = conn.execute(""" + SELECT holder, expires_at FROM locks + WHERE filepath = ? AND julianday('now') < julianday(expires_at) + """, (filepath,)).fetchone() + + if existing and existing['holder'] != holder: + raise HTTPException( + status_code=409, + detail=f"Lock détenu par {existing['holder']} jusqu'à {existing['expires_at']}" + ) + + # Upsert — remplace si même holder ou expiré + conn.execute("DELETE FROM locks WHERE filepath = ?", (filepath,)) + conn.execute(""" + INSERT INTO locks (filepath, holder, claimed_at, expires_at, ttl_min) + VALUES (?, ?, ?, datetime(?, '+' || ? || ' minutes'), ?) + """, (filepath, holder, now, now, ttl_min, ttl_min)) + conn.commit() + log.info('bsi_lock_acquire filepath=%s holder=%s ttl=%dm', filepath, holder, ttl_min) + + await _broadcast({ + 'type': 'bsi:lock:acquire', + 'payload': {'filepath': filepath, 'holder': holder}, + }) + + return {'ok': True, 'filepath': filepath, 'holder': holder} + finally: + conn.close() + + +@app.delete('/bsi/locks/{filepath:path}') +async def bsi_locks_release( + filepath: str, + holder: str = Query(...), + request: Request = None, + authorization: str | None = Header(None), +): + """Libère un lock fichier. Seul le holder peut libérer.""" + if not _is_localhost(request): + scopes = check_auth(authorization) + if 'kernel' not in scopes: + raise HTTPException(status_code=403, detail='Zone kernel requise (owner only)') + + conn = _bsi_conn() + try: + deleted = conn.execute( + "DELETE FROM locks WHERE filepath = ? AND holder = ?", (filepath, holder) + ).rowcount + conn.commit() + + if deleted == 0: + raise HTTPException(status_code=404, detail=f'Lock {filepath} non trouvé pour {holder}') + + log.info('bsi_lock_release filepath=%s holder=%s', filepath, holder) + + await _broadcast({ + 'type': 'bsi:lock:release', + 'payload': {'filepath': filepath, 'holder': holder}, + }) + + return {'ok': True, 'filepath': filepath} + finally: + conn.close() + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _format_results(results: list[dict], full: bool, mode: str) -> dict: + """ + Sérialise les chunks en JSON. + mode=develop → filepath visible + mode=service → filepath masqué (prévu BE-3c — structure prête) + """ + expose_filepath = (mode != 'service') # garde le if pour BE-3c + + items = [] + for r in results: + item = { + 'score': round(r['score'], 4), + 'title': r.get('title') or '', + 'query': r.get('_query', ''), + } + if expose_filepath: + item['filepath'] = r['filepath'] + if full: + item['chunk_text'] = r['chunk_text'] + else: + item['excerpt'] = r['chunk_text'].replace('\n', ' ')[:120].strip() + '…' + items.append(item) + + return {'count': len(items), 'results': items} + + +def _parse_frontmatter(path: Path) -> dict: + """ + Parse le frontmatter YAML d'un fichier Markdown (bloc entre les premiers `---`). + Retourne {} si absent ou en cas d'erreur. + """ + try: + text = path.read_text(encoding='utf-8') + except Exception: + return {} + + m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL) + if not m: + return {} + + raw = m.group(1) + if _YAML_AVAILABLE: + try: + return yaml.safe_load(raw) or {} + except Exception: + pass + + # Fallback : parser simple key: value (une profondeur) + result: dict = {} + for line in raw.splitlines(): + kv = re.match(r'^(\w[\w-]*):\s*(.*)$', line) + if kv: + k, v = kv.group(1), kv.group(2).strip() + # liste inline [a, b, c] + if v.startswith('[') and v.endswith(']'): + items = [x.strip().strip('"\'') for x in v[1:-1].split(',') if x.strip()] + result[k] = items + else: + result[k] = v.strip('"\'') or None + return result + + +def _load_yaml_file(path: Path) -> dict: + """Charge un fichier YAML. Retourne {} si absent ou invalide.""" + try: + text = path.read_text(encoding='utf-8') + except Exception: + return {} + + if _YAML_AVAILABLE: + try: + return yaml.safe_load(text) or {} + except Exception: + return {} + + # Fallback : même parser simple que _parse_frontmatter + result: dict = {} + for line in text.splitlines(): + kv = re.match(r'^(\w[\w-]*):\s*(.*)$', line) + if kv: + k, v = kv.group(1), kv.group(2).strip() + if v.startswith('[') and v.endswith(']'): + items = [x.strip().strip('"\'') for x in v[1:-1].split(',') if x.strip()] + result[k] = items + else: + result[k] = v.strip('"\'') or None + return result + + +def _write_yaml_file(path: Path, data: dict) -> None: + """Écrit un dict en YAML (ou format clé: valeur si yaml indisponible).""" + if _YAML_AVAILABLE: + path.write_text(yaml.dump(data, allow_unicode=True, default_flow_style=False), encoding='utf-8') + return + + # Fallback minimal + lines = [] + for k, v in data.items(): + if isinstance(v, list): + lines.append(f'{k}: [{", ".join(str(i) for i in v)}]') + elif isinstance(v, bool): + lines.append(f'{k}: {"true" if v else "false"}') + elif v is None: + lines.append(f'{k}:') + else: + val = str(v) + if any(c in val for c in (':', '#', '[', ']', '{', '}')): + val = f'"{val}"' + lines.append(f'{k}: {val}') + path.write_text('\n'.join(lines) + '\n', encoding='utf-8') + + +def _parse_agents_tier_map(agents_md: Path) -> dict: + """ + Parse AGENTS.md pour extraire tier et date de création par agent. + Retourne {agent_id: {'tier': 'hot'|'stable'|'kernel', 'created': 'YYYY-MM-DD'}}. + """ + tier_map: dict = {} + try: + text = agents_md.read_text(encoding='utf-8') + except Exception: + return tier_map + + # Détection de section : 🔴 → hot, 🔵 → stable, ⚙️ → kernel + current_tier = 'stable' + for line in text.splitlines(): + if '🔴' in line: + current_tier = 'hot' + elif '🔵' in line: + current_tier = 'stable' + elif '⚙️' in line or '⚙' in line: + current_tier = 'kernel' + # Ligne de tableau : | `agent-name` | ... | ✅ 2026-03-12 | + row = re.match(r'\|\s*`([^`]+)`\s*\|.*\|\s*(.*?)\s*\|?\s*$', line) + if row: + agent_id = row.group(1) + status_col = row.group(2) + date_m = re.search(r'(\d{4}-\d{2}-\d{2})', status_col) + created = date_m.group(1) if date_m else '' + tier_map[agent_id] = {'tier': current_tier, 'created': created} + + return tier_map + + +# ── Entrypoint ───────────────────────────────────────────────────────────────── + +if __name__ == '__main__': + import uvicorn + tiers = ', '.join(sorted(set(_TOKEN_MAP.values()))) if _TOKEN_MAP else 'auth désactivée (dev)' + log.info('Brain-as-a-Service BE-4 — port %d — tokens: %s', BRAIN_PORT, tiers) + uvicorn.run(app, host='0.0.0.0', port=BRAIN_PORT, + forwarded_allow_ips='*', proxy_headers=True) diff --git a/brain-engine/start.sh b/brain-engine/start.sh new file mode 100755 index 0000000..6635a6f --- /dev/null +++ b/brain-engine/start.sh @@ -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" diff --git a/brain-engine/test_brain_engine.py b/brain-engine/test_brain_engine.py new file mode 100644 index 0000000..53abee4 --- /dev/null +++ b/brain-engine/test_brain_engine.py @@ -0,0 +1,1312 @@ +#!/usr/bin/env python3 +""" +brain-engine/test_brain_engine.py — Tests unitaires BE-2 +Stdlib uniquement (unittest). Aucun accès réseau, Ollama, ou brain.db requis. + +Lancer : + python3 brain-engine/test_brain_engine.py + python3 brain-engine/test_brain_engine.py -v +""" + +import sys +import os +import unittest +import tempfile +import struct +from pathlib import Path +from unittest.mock import patch, MagicMock + +# ── Import des modules sous test ─────────────────────────────────────────────── +# Les modules ont un guardrail EMBED_MODEL au niveau module — nomic-embed-text +# (défaut) passe ; on s'assure de ne pas avoir de variable bloquante. +os.environ.setdefault('EMBED_MODEL', 'nomic-embed-text') + +sys.path.insert(0, str(Path(__file__).parent)) +import embed +import search +import distill + + +# ══════════════════════════════════════════════════════════════════════════════ +# embed.py — chunk_by_size +# ══════════════════════════════════════════════════════════════════════════════ + +class TestChunkBySize(unittest.TestCase): + + def test_short_text_single_chunk(self): + """Texte plus court que max_chars → 1 seul chunk.""" + text = "Bonjour le monde." + chunks = embed.chunk_by_size(text, "test.md") + self.assertEqual(len(chunks), 1) + self.assertEqual(chunks[0]['text'], text) + + def test_long_text_multiple_chunks(self): + """Texte long → plusieurs chunks, tous non vides.""" + text = "A" * (embed.CHUNK_TOKENS * 4 * 3) # 3× la taille max + chunks = embed.chunk_by_size(text, "test.md") + self.assertGreater(len(chunks), 1) + for c in chunks: + self.assertTrue(c['text']) + + def test_no_infinite_loop_regression(self): + """ + RÉGRESSION — bug boucle infinie (corrigé 2026-03-16). + Tout texte > CHUNK_TOKENS*4 sans saut de ligne déclenchait une boucle + infinie : start = end - overlap restait toujours < len(text). + Ce test doit terminer en < 1s. + """ + import signal + + def timeout_handler(signum, frame): + raise TimeoutError("chunk_by_size en boucle infinie !") + + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(2) # 2 secondes max + try: + # Texte sans saut de ligne — le cas exact qui causait le freeze + text = "X" * (embed.CHUNK_TOKENS * 4 + 100) + chunks = embed.chunk_by_size(text, "test.md") + self.assertGreater(len(chunks), 0) + finally: + signal.alarm(0) + + def test_no_infinite_loop_with_newlines(self): + """Texte avec sauts de ligne — variante avec newlines.""" + import signal + + def timeout_handler(signum, frame): + raise TimeoutError("chunk_by_size en boucle infinie !") + + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(2) + try: + line = "Ligne de texte normale.\n" + text = line * 300 # ~7 200 chars + chunks = embed.chunk_by_size(text, "test.md") + self.assertGreater(len(chunks), 1) + finally: + signal.alarm(0) + + def test_chunks_cover_full_text(self): + """Vérifier que les chunks couvrent bien tout le texte (pas de trou).""" + text = "mot " * 1000 # ~4 000 chars + chunks = embed.chunk_by_size(text, "test.md") + # Le dernier chunk doit contenir la fin du texte + last_chunk = chunks[-1]['text'] + self.assertIn("mot", last_chunk) + + def test_filepath_preserved(self): + """Le filepath est propagé dans chaque chunk.""" + text = "A" * (embed.CHUNK_TOKENS * 4 + 1) + fp = "agents/helloWorld.md" + chunks = embed.chunk_by_size(text, fp) + for c in chunks: + self.assertEqual(c['filepath'], fp) + + def test_empty_text(self): + """Texte vide → aucun chunk.""" + chunks = embed.chunk_by_size("", "test.md") + self.assertEqual(len(chunks), 0) + + def test_whitespace_only(self): + """Texte uniquement whitespace → aucun chunk.""" + chunks = embed.chunk_by_size(" \n\n ", "test.md") + self.assertEqual(len(chunks), 0) + + +# ══════════════════════════════════════════════════════════════════════════════ +# embed.py — chunk_by_h2 +# ══════════════════════════════════════════════════════════════════════════════ + +class TestChunkByH2(unittest.TestCase): + + def test_single_section(self): + """Un seul fichier sans H2 → 1 chunk.""" + text = "# Titre\n\nContenu simple sans section H2." + chunks = embed.chunk_by_h2(text, "test.md") + self.assertEqual(len(chunks), 1) + + def test_multiple_h2_sections(self): + """Plusieurs sections H2 → un chunk par section.""" + text = "## Section 1\nContenu 1\n\n## Section 2\nContenu 2\n\n## Section 3\nContenu 3" + chunks = embed.chunk_by_h2(text, "test.md") + self.assertEqual(len(chunks), 3) + + def test_section_title_extracted(self): + """Le titre H2 est extrait proprement.""" + text = "## Mon Agent\nContenu de l'agent." + chunks = embed.chunk_by_h2(text, "test.md") + self.assertEqual(chunks[0]['title'], 'Mon Agent') + + def test_long_section_sub_chunked(self): + """Section H2 trop longue → sous-chunking par taille.""" + long_content = "X" * (embed.CHUNK_TOKENS * 4 + 100) + text = f"## Section longue\n{long_content}" + chunks = embed.chunk_by_h2(text, "test.md") + # Doit produire plusieurs chunks, pas boucler + self.assertGreater(len(chunks), 1) + + def test_empty_text_fallback(self): + """Texte vide → chunk_by_h2 retourne 1 chunk (vide) — chunk_file filtre en amont.""" + chunks = embed.chunk_by_h2("", "test.md") + # Le fallback retourne toujours au moins 1 chunk ; chunk_file gère le cas vide avant appel + self.assertEqual(len(chunks), 1) + + +# ══════════════════════════════════════════════════════════════════════════════ +# embed.py — utilitaires +# ══════════════════════════════════════════════════════════════════════════════ + +class TestEmbedUtils(unittest.TestCase): + + def test_chunk_id_deterministic(self): + """Même input → même chunk_id.""" + cid1 = embed.chunk_id("agents/test.md", "contenu du chunk") + cid2 = embed.chunk_id("agents/test.md", "contenu du chunk") + self.assertEqual(cid1, cid2) + + def test_chunk_id_different_inputs(self): + """Inputs différents → chunk_id différents.""" + cid1 = embed.chunk_id("agents/test.md", "contenu A") + cid2 = embed.chunk_id("agents/test.md", "contenu B") + self.assertNotEqual(cid1, cid2) + + def test_chunk_id_format(self): + """chunk_id commence par 'emb-'.""" + cid = embed.chunk_id("test.md", "texte") + self.assertTrue(cid.startswith("emb-")) + + def test_vector_roundtrip(self): + """Sérialiser puis désérialiser un vecteur → valeurs identiques.""" + vec = [0.1, 0.2, 0.3, -0.5, 1.0] + blob = embed.vector_to_blob(vec) + restored = embed.blob_to_vector(blob) + for a, b in zip(vec, restored): + self.assertAlmostEqual(a, b, places=5) + + def test_should_exclude_brain_engine(self): + """brain-engine/ est exclu.""" + p = Path("/brain/brain-engine/embed.py") + self.assertTrue(embed.should_exclude(p)) + + def test_should_exclude_git(self): + """.git/ est exclu.""" + p = Path("/brain/.git/config") + self.assertTrue(embed.should_exclude(p)) + + def test_should_not_exclude_agents(self): + """agents/ n'est pas exclu.""" + p = Path("/brain/agents/helloWorld.md") + self.assertFalse(embed.should_exclude(p)) + + def test_should_not_exclude_claims(self): + """claims/ n'est pas exclu.""" + p = Path("/brain/claims/sess-20260316-test.yml") + self.assertFalse(embed.should_exclude(p)) + + +# ══════════════════════════════════════════════════════════════════════════════ +# search.py — cosine_sim +# ══════════════════════════════════════════════════════════════════════════════ + +class TestCosineSim(unittest.TestCase): + + def test_identical_vectors(self): + """Vecteurs identiques → similarité 1.0.""" + v = [0.1, 0.5, -0.3, 0.8] + self.assertAlmostEqual(search.cosine_sim(v, v), 1.0, places=5) + + def test_opposite_vectors(self): + """Vecteurs opposés → similarité -1.0.""" + v = [1.0, 0.0, 0.0] + w = [-1.0, 0.0, 0.0] + self.assertAlmostEqual(search.cosine_sim(v, w), -1.0, places=5) + + def test_orthogonal_vectors(self): + """Vecteurs orthogonaux → similarité 0.0.""" + v = [1.0, 0.0] + w = [0.0, 1.0] + self.assertAlmostEqual(search.cosine_sim(v, w), 0.0, places=5) + + def test_zero_vector(self): + """Vecteur nul → similarité 0.0 (pas de division par zéro).""" + v = [0.0, 0.0, 0.0] + w = [1.0, 2.0, 3.0] + self.assertEqual(search.cosine_sim(v, w), 0.0) + + def test_symmetry(self): + """cosine_sim(a, b) == cosine_sim(b, a).""" + a = [0.3, -0.1, 0.7] + b = [0.5, 0.2, -0.4] + self.assertAlmostEqual(search.cosine_sim(a, b), search.cosine_sim(b, a), places=10) + + def test_range(self): + """Résultat toujours dans [-1, 1].""" + import random + random.seed(42) + for _ in range(50): + a = [random.uniform(-1, 1) for _ in range(768)] + b = [random.uniform(-1, 1) for _ in range(768)] + sim = search.cosine_sim(a, b) + self.assertGreaterEqual(sim, -1.0 - 1e-6) + self.assertLessEqual(sim, 1.0 + 1e-6) + + +# ══════════════════════════════════════════════════════════════════════════════ +# search.py — blob_to_vector +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSearchUtils(unittest.TestCase): + + def test_blob_to_vector_roundtrip(self): + """Blob → vecteur cohérent avec embed.vector_to_blob.""" + vec = [0.1, -0.2, 0.5, 1.0, -0.9] + blob = embed.vector_to_blob(vec) + restored = search.blob_to_vector(blob) + for a, b in zip(vec, restored): + self.assertAlmostEqual(a, b, places=5) + + def test_blob_to_vector_768_dims(self): + """Blob de 768 floats → vecteur de 768 éléments.""" + vec = [float(i) / 768 for i in range(768)] + blob = embed.vector_to_blob(vec) + restored = search.blob_to_vector(blob) + self.assertEqual(len(restored), 768) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Intégration — dry-run sur un fichier temporaire +# ══════════════════════════════════════════════════════════════════════════════ + +class TestIntegrationDryRun(unittest.TestCase): + + def test_chunk_file_markdown(self): + """chunk_file sur un vrai fichier .md → chunks non vides.""" + with tempfile.NamedTemporaryFile(suffix='.md', mode='w', delete=False) as f: + f.write("## Section A\nContenu de la section A.\n\n## Section B\nContenu B.\n") + tmp = Path(f.name) + try: + with patch.object(embed, 'BRAIN_ROOT', tmp.parent): + chunks = embed.chunk_file(tmp, 'h2') + self.assertEqual(len(chunks), 2) + self.assertEqual(chunks[0]['title'], 'Section A') + self.assertEqual(chunks[1]['title'], 'Section B') + finally: + tmp.unlink() + + def test_chunk_file_large_no_hang(self): + """ + RÉGRESSION — chunk_file sur un fichier large (strategy=file) + ne doit pas boucler indéfiniment. + """ + import signal + + def timeout_handler(signum, frame): + raise TimeoutError("chunk_file en boucle infinie !") + + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(3) + try: + with tempfile.NamedTemporaryFile(suffix='.md', mode='w', delete=False) as f: + # focus.md fait ~11K — reproduire le cas exact du bug + f.write("Contenu sans saut de ligne.\n" * 400) # ~11 200 chars + tmp = Path(f.name) + with patch.object(embed, 'BRAIN_ROOT', tmp.parent): + chunks = embed.chunk_file(tmp, 'file') + self.assertGreater(len(chunks), 0) + finally: + signal.alarm(0) + tmp.unlink() + + +# ══════════════════════════════════════════════════════════════════════════════ +# migrate.py — parse_yml_field +# ══════════════════════════════════════════════════════════════════════════════ + +import migrate + +class TestParseYmlField(unittest.TestCase): + + def test_basic_field(self): + content = "sess_id: sess-20260316-test\nstatus: open\n" + self.assertEqual(migrate.parse_yml_field(content, 'sess_id'), 'sess-20260316-test') + self.assertEqual(migrate.parse_yml_field(content, 'status'), 'open') + + def test_quoted_value(self): + content = 'story_angle: "Reprise BE-2 après crash"\n' + self.assertEqual(migrate.parse_yml_field(content, 'story_angle'), 'Reprise BE-2 après crash') + + def test_missing_field_returns_default(self): + content = "sess_id: test\n" + self.assertIsNone(migrate.parse_yml_field(content, 'closed_at')) + self.assertEqual(migrate.parse_yml_field(content, 'closed_at', 'fallback'), 'fallback') + + def test_field_with_spaces(self): + content = "opened_at: 2026-03-16T13:40 \n" + self.assertEqual(migrate.parse_yml_field(content, 'opened_at'), '2026-03-16T13:40') + + +# ══════════════════════════════════════════════════════════════════════════════ +# migrate.py — migrate_claims + migrate_sessions (BE-2b) +# ══════════════════════════════════════════════════════════════════════════════ + +SCHEMA_PATH = Path(__file__).parent / 'schema.sql' + +def make_in_memory_db(): + """Crée une DB SQLite in-memory avec le schéma brain + table embeddings.""" + import sqlite3 + conn = sqlite3.connect(':memory:') + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + with open(SCHEMA_PATH) as f: + conn.executescript(f.read()) + # Table embeddings créée par embed.connect() — pas dans schema.sql + 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, + model TEXT, + indexed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.commit() + return conn + + +class TestMigrateClaims(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.claims_dir = Path(self.tmpdir.name) / 'claims' + self.claims_dir.mkdir() + + def tearDown(self): + self.tmpdir.cleanup() + + def _write_claim(self, filename, content): + (self.claims_dir / filename).write_text(content) + + def test_migrate_single_claim(self): + """Un claim valide est inséré dans la table claims.""" + self._write_claim('sess-20260316-test.yml', """ +sess_id: sess-20260316-test +type: build-brain +scope: shadow-be2 +status: open +opened_at: "2026-03-16T13:00" +handoff_level: FULL +""") + conn = make_in_memory_db() + with patch.object(migrate, 'BRAIN_ROOT', self.tmpdir.name): + count = migrate.migrate_claims(conn) + self.assertEqual(count, 1) + row = conn.execute("SELECT * FROM claims WHERE sess_id='sess-20260316-test'").fetchone() + self.assertIsNotNone(row) + self.assertEqual(row['status'], 'open') + self.assertEqual(row['scope'], 'shadow-be2') + + def test_migrate_multiple_claims(self): + """Plusieurs claims — tous insérés.""" + for i in range(3): + self._write_claim(f'sess-2026031{i}-test.yml', f""" +sess_id: sess-2026031{i}-test +type: build-brain +scope: brain/ +status: closed +opened_at: "2026-03-1{i}T10:00" +""") + conn = make_in_memory_db() + with patch.object(migrate, 'BRAIN_ROOT', self.tmpdir.name): + count = migrate.migrate_claims(conn) + self.assertEqual(count, 3) + + def test_migrate_idempotent(self): + """Relancer migrate_claims ne duplique pas les données.""" + self._write_claim('sess-20260316-idem.yml', """ +sess_id: sess-20260316-idem +type: brain +scope: brain/ +status: open +opened_at: "2026-03-16T10:00" +""") + conn = make_in_memory_db() + with patch.object(migrate, 'BRAIN_ROOT', self.tmpdir.name): + migrate.migrate_claims(conn) + migrate.migrate_claims(conn) + total = conn.execute("SELECT COUNT(*) FROM claims").fetchone()[0] + self.assertEqual(total, 1) + + def test_migrate_skips_non_yml(self): + """Fichiers non .yml ignorés.""" + self._write_claim('README.md', "# not a claim") + self._write_claim('sess-20260316-ok.yml', """ +sess_id: sess-20260316-ok +type: brain +scope: brain/ +status: closed +opened_at: "2026-03-16T10:00" +""") + conn = make_in_memory_db() + with patch.object(migrate, 'BRAIN_ROOT', self.tmpdir.name): + count = migrate.migrate_claims(conn) + self.assertEqual(count, 1) + + +class TestMigrateSessions(unittest.TestCase): + """BE-2b — migrate_sessions dérive sessions depuis claims.""" + + def _setup_claims(self, conn, claims): + """Insère des claims directement en DB (sans passer par migrate_claims).""" + for c in claims: + conn.execute(""" + INSERT INTO claims(sess_id, type, scope, status, opened_at, handoff_level) + VALUES (?,?,?,?,?,?) + """, (c['sess_id'], c.get('type','brain'), c.get('scope','brain/'), + c.get('status','closed'), c.get('opened_at','2026-03-16T10:00'), + c.get('handoff_level','FULL'))) + conn.commit() + + def test_sessions_created_from_claims(self): + """migrate_sessions crée autant de sessions que de claims.""" + conn = make_in_memory_db() + self._setup_claims(conn, [ + {'sess_id': 'sess-A', 'opened_at': '2026-03-16T10:00'}, + {'sess_id': 'sess-B', 'opened_at': '2026-03-16T11:00'}, + {'sess_id': 'sess-C', 'opened_at': '2026-03-16T12:00'}, + ]) + count = migrate.migrate_sessions(conn) + self.assertEqual(count, 3) + + def test_date_extracted_from_opened_at(self): + """La date ISO est tronquée à YYYY-MM-DD dans sessions.date.""" + conn = make_in_memory_db() + self._setup_claims(conn, [ + {'sess_id': 'sess-date-test', 'opened_at': '2026-03-16T13:40'}, + ]) + migrate.migrate_sessions(conn) + row = conn.execute("SELECT date FROM sessions WHERE sess_id='sess-date-test'").fetchone() + self.assertEqual(row['date'], '2026-03-16') + + def test_sessions_idempotent(self): + """Relancer migrate_sessions ne duplique pas les sessions.""" + conn = make_in_memory_db() + self._setup_claims(conn, [ + {'sess_id': 'sess-idem', 'opened_at': '2026-03-16T10:00'}, + ]) + migrate.migrate_sessions(conn) + migrate.migrate_sessions(conn) + total = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] + self.assertEqual(total, 1) + + def test_handoff_level_preserved(self): + """handoff_level est propagé de claims vers sessions.""" + conn = make_in_memory_db() + self._setup_claims(conn, [ + {'sess_id': 'sess-hl', 'opened_at': '2026-03-16T10:00', 'handoff_level': 'FULL'}, + ]) + migrate.migrate_sessions(conn) + row = conn.execute("SELECT handoff_level FROM sessions WHERE sess_id='sess-hl'").fetchone() + self.assertEqual(row['handoff_level'], 'FULL') + + def test_empty_claims_zero_sessions(self): + """Aucun claim → aucune session.""" + conn = make_in_memory_db() + count = migrate.migrate_sessions(conn) + self.assertEqual(count, 0) + + +# ══════════════════════════════════════════════════════════════════════════════ +# search.py — search() avec Ollama mocké + DB in-memory +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSearchFunction(unittest.TestCase): + + def _make_db_with_vectors(self, entries): + """ + Crée une DB in-memory avec des embeddings pré-calculés. + entries = [{'filepath', 'title', 'chunk_text', 'vector'}] + """ + conn = make_in_memory_db() + for e in entries: + blob = embed.vector_to_blob(e['vector']) + cid = embed.chunk_id(e['filepath'], e['chunk_text']) + conn.execute(""" + INSERT INTO embeddings(chunk_id, filepath, title, chunk_text, vector, model, indexed) + VALUES (?,?,?,?,?,?,1) + """, (cid, e['filepath'], e['title'], e['chunk_text'], blob, 'nomic-embed-text')) + conn.commit() + return conn + + def _patch_search(self, query_vec, db_conn): + """Patche embed_query + sqlite3.connect pour les tests.""" + return ( + patch.object(search, 'embed_query', return_value=query_vec), + patch('sqlite3.connect', return_value=db_conn), + ) + + def test_returns_top_k_results(self): + """search() retourne au plus top_k résultats.""" + # Vecteur query : [1, 0, 0] + # 3 chunks avec vecteurs variés + entries = [ + {'filepath': 'agents/a.md', 'title': 'A', 'chunk_text': 'chunk A', + 'vector': [1.0, 0.0, 0.0]}, # cos_sim = 1.0 — le plus proche + {'filepath': 'agents/b.md', 'title': 'B', 'chunk_text': 'chunk B', + 'vector': [0.0, 1.0, 0.0]}, # cos_sim = 0.0 + {'filepath': 'agents/c.md', 'title': 'C', 'chunk_text': 'chunk C', + 'vector': [-1.0, 0.0, 0.0]}, # cos_sim = -1.0 — le plus loin + ] + db = self._make_db_with_vectors(entries) + p1, p2 = self._patch_search([1.0, 0.0, 0.0], db) + with p1, p2: + results = search.search("test", top_k=2) + self.assertEqual(len(results), 2) + + def test_results_sorted_by_score_desc(self): + """Les résultats sont triés par score décroissant.""" + entries = [ + {'filepath': 'a.md', 'title': '', 'chunk_text': 'A', 'vector': [1.0, 0.0, 0.0]}, + {'filepath': 'b.md', 'title': '', 'chunk_text': 'B', 'vector': [0.5, 0.5, 0.0]}, + {'filepath': 'c.md', 'title': '', 'chunk_text': 'C', 'vector': [0.0, 0.0, 1.0]}, + ] + db = self._make_db_with_vectors(entries) + p1, p2 = self._patch_search([1.0, 0.0, 0.0], db) + with p1, p2: + results = search.search("test", top_k=3) + scores = [r['score'] for r in results] + self.assertEqual(scores, sorted(scores, reverse=True)) + + def test_best_match_is_correct(self): + """Le chunk le plus proche de la query est bien le premier résultat.""" + entries = [ + {'filepath': 'best.md', 'title': 'Best', 'chunk_text': 'best chunk', + 'vector': [1.0, 0.0, 0.0]}, + {'filepath': 'worst.md', 'title': 'Worst', 'chunk_text': 'worst chunk', + 'vector': [0.0, 1.0, 0.0]}, + ] + db = self._make_db_with_vectors(entries) + p1, p2 = self._patch_search([1.0, 0.0, 0.0], db) + with p1, p2: + results = search.search("test", top_k=2) + self.assertEqual(results[0]['filepath'], 'best.md') + + def test_min_score_filter(self): + """Les chunks sous le score minimum sont filtrés.""" + entries = [ + {'filepath': 'high.md', 'title': '', 'chunk_text': 'H', + 'vector': [1.0, 0.0, 0.0]}, # cos = 1.0 + {'filepath': 'low.md', 'title': '', 'chunk_text': 'L', + 'vector': [0.0, 1.0, 0.0]}, # cos = 0.0 + ] + db = self._make_db_with_vectors(entries) + p1, p2 = self._patch_search([1.0, 0.0, 0.0], db) + with p1, p2: + results = search.search("test", top_k=5, min_score=0.5) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['filepath'], 'high.md') + + def test_ollama_unavailable_returns_empty(self): + """Si Ollama est indisponible (embed_query=None) → liste vide.""" + with patch.object(search, 'embed_query', return_value=None): + results = search.search("test") + self.assertEqual(results, []) + + def test_empty_db_returns_empty(self): + """DB sans vecteurs → liste vide + warning.""" + db = make_in_memory_db() # DB vide, table embeddings existe mais 0 rows + p1, p2 = self._patch_search([1.0, 0.0, 0.0], db) + with p1, p2: + results = search.search("test") + self.assertEqual(results, []) + + +# ══════════════════════════════════════════════════════════════════════════════ +# rag.py — BE-3a : couche RAG (formatters, déduplication, skip helloWorld) +# ══════════════════════════════════════════════════════════════════════════════ + +import rag + + +def _make_hit(filepath, score, chunk_text='chunk', title='', query='q'): + return { + 'filepath': filepath, + 'score': score, + 'title': title, + 'chunk_text': chunk_text, + '_query': query, + } + + +class TestRagConstants(unittest.TestCase): + + def test_helloworld_skip_contains_focus(self): + """focus.md est dans HELLOWORLD_SKIP (chargé par helloWorld).""" + self.assertIn('focus.md', rag.HELLOWORLD_SKIP) + + def test_helloworld_skip_contains_kernel(self): + self.assertIn('KERNEL.md', rag.HELLOWORLD_SKIP) + + def test_helloworld_skip_does_not_contain_adr(self): + """Les ADRs ne sont pas dans HELLOWORLD_SKIP — ils doivent remonter.""" + self.assertFalse(any('ADR' in p for p in rag.HELLOWORLD_SKIP)) + + def test_three_boot_queries(self): + """Exactement 3 queries boot définies.""" + self.assertEqual(len(rag.RAG_BOOT_QUERIES), 3) + + def test_boot_queries_have_top_k(self): + """Chaque query boot a un top_k > 0.""" + for query, top_k in rag.RAG_BOOT_QUERIES: + self.assertIsInstance(query, str) + self.assertGreater(top_k, 0) + + +class TestRagFormatCompact(unittest.TestCase): + + def test_empty_returns_empty_string(self): + self.assertEqual(rag.format_compact([]), '') + + def test_contains_header(self): + results = [_make_hit('ADR/001.md', 0.80)] + out = rag.format_compact(results) + self.assertIn('## Brain context', out) + + def test_contains_filepath(self): + results = [_make_hit('ADR/001.md', 0.80)] + out = rag.format_compact(results) + self.assertIn('ADR/001.md', out) + + def test_contains_score(self): + results = [_make_hit('ADR/001.md', 0.82)] + out = rag.format_compact(results) + self.assertIn('0.82', out) + + def test_title_prepended_to_excerpt(self): + results = [_make_hit('f.md', 0.70, chunk_text='body', title='MyTitle')] + out = rag.format_compact(results) + self.assertIn('[MyTitle]', out) + + def test_query_grouping_header(self): + """Deux résultats avec queries différentes → 2 sous-headers.""" + results = [ + _make_hit('a.md', 0.80, query='query A'), + _make_hit('b.md', 0.70, query='query B'), + ] + out = rag.format_compact(results) + self.assertIn('### query A', out) + self.assertIn('### query B', out) + + def test_excerpt_max_120_chars(self): + """L'extrait est tronqué à 120 chars.""" + long_text = 'X' * 300 + results = [_make_hit('f.md', 0.70, chunk_text=long_text)] + out = rag.format_compact(results) + # L'extrait dans la ligne = 120 chars max + "…" → ligne < 200 chars hors metadata + line = [l for l in out.splitlines() if 'f.md' in l][0] + # la partie après "— " est l'extrait ; on vérifie qu'il est borné + excerpt_part = line.split('— ', 1)[1] if '— ' in line else '' + self.assertLessEqual(len(excerpt_part.rstrip('…')), 120) + + def test_custom_label(self): + results = [_make_hit('f.md', 0.70)] + out = rag.format_compact(results, label='RAG — test') + self.assertIn('RAG — test', out) + + +class TestRagFormatFull(unittest.TestCase): + + def test_empty_returns_empty_string(self): + self.assertEqual(rag.format_full([]), '') + + def test_contains_full_chunk_text(self): + long_text = 'Contenu complet ' * 20 + results = [_make_hit('f.md', 0.75, chunk_text=long_text)] + out = rag.format_full(results) + self.assertIn(long_text.strip(), out) + + def test_contains_filepath_header(self): + results = [_make_hit('ADR/002.md', 0.75)] + out = rag.format_full(results) + self.assertIn('ADR/002.md', out) + + def test_custom_label(self): + results = [_make_hit('f.md', 0.70)] + out = rag.format_full(results, label='RAG — full') + self.assertIn('RAG — full', out) + + +class TestRagFormatJson(unittest.TestCase): + + def test_empty_returns_empty_list(self): + import json + self.assertEqual(json.loads(rag.format_json([])), []) + + def test_fields_present(self): + import json + results = [_make_hit('f.md', 0.80, chunk_text='text', title='T', query='q')] + out = json.loads(rag.format_json(results)) + self.assertEqual(len(out), 1) + self.assertIn('score', out[0]) + self.assertIn('filepath', out[0]) + self.assertIn('title', out[0]) + self.assertIn('chunk_text', out[0]) + self.assertIn('query', out[0]) + + def test_score_rounded(self): + import json + results = [_make_hit('f.md', 0.123456789)] + out = json.loads(rag.format_json(results)) + self.assertEqual(out[0]['score'], round(0.123456789, 4)) + + +class TestRagBootDeduplication(unittest.TestCase): + """ + Teste la logique de déduplication et skip helloWorld de run_boot_queries(). + Mocke semantic_search pour rester headless. + """ + + def _mock_search(self, hits_by_query): + """ + hits_by_query = {query_str: [hit_dicts]} + Retourne une fonction qui joue le rôle de semantic_search. + """ + def _search(query, top_k=5, min_score=0.0, allowed_scopes=None): + return hits_by_query.get(query, []) + return _search + + def test_skip_helloworld_files(self): + """Les fichiers HELLOWORLD_SKIP ne remontent pas dans les résultats boot.""" + hits = { + rag.RAG_BOOT_QUERIES[0][0]: [ + _make_hit('focus.md', 0.90), # doit être skippé + _make_hit('ADR/001.md', 0.80), # doit passer + ], + rag.RAG_BOOT_QUERIES[1][0]: [], + rag.RAG_BOOT_QUERIES[2][0]: [], + } + with patch.object(rag, 'semantic_search', self._mock_search(hits)): + results = rag.run_boot_queries() + filepaths = [r['filepath'] for r in results] + self.assertNotIn('focus.md', filepaths) + self.assertIn('ADR/001.md', filepaths) + + def test_deduplication_across_queries(self): + """Un filepath qui remonte dans 2 queries différentes n'est inclus qu'une fois.""" + common = _make_hit('workspace/sprint.md', 0.75, query=rag.RAG_BOOT_QUERIES[0][0]) + hits = { + rag.RAG_BOOT_QUERIES[0][0]: [common], + rag.RAG_BOOT_QUERIES[1][0]: [_make_hit('workspace/sprint.md', 0.65, + query=rag.RAG_BOOT_QUERIES[1][0])], + rag.RAG_BOOT_QUERIES[2][0]: [], + } + with patch.object(rag, 'semantic_search', self._mock_search(hits)): + results = rag.run_boot_queries() + filepaths = [r['filepath'] for r in results] + self.assertEqual(filepaths.count('workspace/sprint.md'), 1) + + def test_query_tag_preserved(self): + """Chaque résultat conserve le tag _query de la query qui l'a produit.""" + q0 = rag.RAG_BOOT_QUERIES[0][0] + hits = { + q0: [_make_hit('ADR/001.md', 0.80, query=q0)], + rag.RAG_BOOT_QUERIES[1][0]: [], + rag.RAG_BOOT_QUERIES[2][0]: [], + } + with patch.object(rag, 'semantic_search', self._mock_search(hits)): + results = rag.run_boot_queries() + self.assertEqual(results[0]['_query'], q0) + + def test_empty_results_when_ollama_down(self): + """Si semantic_search retourne [] partout → run_boot_queries retourne [].""" + hits = {q: [] for q, _ in rag.RAG_BOOT_QUERIES} + with patch.object(rag, 'semantic_search', self._mock_search(hits)): + results = rag.run_boot_queries() + self.assertEqual(results, []) + + +# ══════════════════════════════════════════════════════════════════════════════ +# server.py — BE-3b : Brain-as-a-Service (headless — pas de serveur réel lancé) +# ══════════════════════════════════════════════════════════════════════════════ + +import server as srv +from fastapi.testclient import TestClient + + +class TestServerAuth(unittest.TestCase): + """Auth via Authorization: Bearer — sans token = dev, avec token = vérifié.""" + + def setUp(self): + self.client = TestClient(srv.app, raise_server_exceptions=False) + + def test_no_token_env_allows_any_request(self): + """Sans token configuré → pas de contrôle d'accès.""" + with patch.object(srv, '_TOKEN_MAP', {}): + with patch.object(srv, 'run_single_query', return_value=[]): + resp = self.client.get('/search?q=test') + self.assertEqual(resp.status_code, 200) + + def test_valid_token_accepted(self): + """Bearer token correct → 200.""" + with patch.object(srv, '_TOKEN_MAP', {'secret': 'owner'}): + with patch.object(srv, 'run_single_query', return_value=[]): + resp = self.client.get('/search?q=test', + headers={'Authorization': 'Bearer secret'}) + self.assertEqual(resp.status_code, 200) + + def test_wrong_token_rejected(self): + """Bearer token incorrect → 403.""" + with patch.object(srv, '_TOKEN_MAP', {'secret': 'owner'}): + resp = self.client.get('/search?q=test', + headers={'Authorization': 'Bearer wrong'}) + self.assertEqual(resp.status_code, 403) + + def test_missing_header_rejected(self): + """Header absent quand token requis → 401.""" + with patch.object(srv, '_TOKEN_MAP', {'secret': 'owner'}): + resp = self.client.get('/search?q=test') + self.assertEqual(resp.status_code, 401) + + def test_token_not_in_query_param(self): + """Token passé en query param n'est pas accepté comme auth.""" + with patch.object(srv, '_TOKEN_MAP', {'secret': 'owner'}): + resp = self.client.get('/search?q=test&token=secret') + self.assertIn(resp.status_code, [401, 422]) # pas autorisé, pas d'exception + + +class TestServerFormatResults(unittest.TestCase): + """_format_results — filepath visibility, excerpt vs full, mode.""" + + def _hit(self, filepath='f.md', score=0.80, chunk='body', title='T'): + return {'filepath': filepath, 'score': score, + 'chunk_text': chunk, 'title': title, '_query': 'q'} + + def test_develop_mode_exposes_filepath(self): + out = srv._format_results([self._hit()], full=False, mode='develop') + self.assertIn('filepath', out['results'][0]) + + def test_service_mode_hides_filepath(self): + out = srv._format_results([self._hit()], full=False, mode='service') + self.assertNotIn('filepath', out['results'][0]) + + def test_compact_mode_has_excerpt_not_chunk(self): + out = srv._format_results([self._hit(chunk='full content')], full=False, mode='develop') + item = out['results'][0] + self.assertIn('excerpt', item) + self.assertNotIn('chunk_text', item) + + def test_full_mode_has_chunk_text(self): + out = srv._format_results([self._hit(chunk='full content')], full=True, mode='develop') + item = out['results'][0] + self.assertIn('chunk_text', item) + self.assertEqual(item['chunk_text'], 'full content') + + def test_count_matches_results(self): + hits = [self._hit('a.md'), self._hit('b.md')] + out = srv._format_results(hits, full=False, mode='develop') + self.assertEqual(out['count'], 2) + self.assertEqual(len(out['results']), 2) + + def test_score_rounded_to_4_decimals(self): + out = srv._format_results([self._hit(score=0.123456789)], full=False, mode='develop') + self.assertEqual(out['results'][0]['score'], round(0.123456789, 4)) + + def test_empty_results(self): + out = srv._format_results([], full=False, mode='develop') + self.assertEqual(out, {'count': 0, 'results': []}) + + +class TestServerRoutes(unittest.TestCase): + """Routes /health /search /boot — mocked moteur.""" + + def setUp(self): + self.client = TestClient(srv.app) + + def test_health_returns_ok(self): + with patch('sqlite3.connect') as mock_conn: + mock_conn.return_value.__enter__ = lambda s: s + mock_conn.return_value.execute.return_value.fetchone.return_value = [42] + # Appel direct de la fonction + resp = self.client.get('/health') + self.assertIn(resp.status_code, [200, 503]) # 503 si brain.db absent en CI + + def test_search_requires_q(self): + """GET /search sans ?q → 422 Unprocessable.""" + with patch.object(srv, '_TOKEN_MAP', {}): + resp = self.client.get('/search') + self.assertEqual(resp.status_code, 422) + + def test_search_mode_logged(self): + """Le champ mode est accepté sans erreur.""" + with patch.object(srv, '_TOKEN_MAP', {}): + with patch.object(srv, 'run_single_query', return_value=[]): + resp = self.client.get('/search?q=test&mode=develop') + self.assertEqual(resp.status_code, 200) + + def test_boot_endpoint_exists(self): + """GET /boot répond (moteur mocké).""" + with patch.object(srv, '_TOKEN_MAP', {}): + with patch.object(srv, 'run_boot_queries', return_value=[]): + resp = self.client.get('/boot') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['count'], 0) + + +class TestServerScript(unittest.TestCase): + """bsi-server.sh — existence et exécutabilité.""" + + SERVER_SCRIPT = Path(__file__).parent.parent / 'scripts' / 'bsi-server.sh' + + def test_script_exists(self): + self.assertTrue(self.SERVER_SCRIPT.exists()) + + def test_script_executable(self): + self.assertTrue(os.access(self.SERVER_SCRIPT, os.X_OK)) + + def test_invalid_command_exits_nonzero(self): + result = subprocess.run( + ['bash', str(self.SERVER_SCRIPT), 'invalid'], + capture_output=True, text=True + ) + self.assertNotEqual(result.returncode, 0) + + +class TestServerBe3c(unittest.TestCase): + """BE-3c — mode=service masquage filepath, fichiers VPS.""" + + def setUp(self): + self.client = TestClient(srv.app) + + def test_service_mode_hides_filepath_end_to_end(self): + """GET /search?mode=service → pas de filepath dans la réponse JSON.""" + hit = {'filepath': 'secret/path.md', 'score': 0.9, + 'chunk_text': 'body', 'title': 'T', '_query': 'q'} + with patch.object(srv, '_TOKEN_MAP', {}): + with patch.object(srv, 'run_single_query', return_value=[hit]): + resp = self.client.get('/search?q=test&mode=service') + self.assertEqual(resp.status_code, 200) + item = resp.json()['results'][0] + self.assertNotIn('filepath', item) + + def test_develop_mode_exposes_filepath_end_to_end(self): + """GET /search?mode=develop → filepath présent dans la réponse JSON.""" + hit = {'filepath': 'ADR/001.md', 'score': 0.9, + 'chunk_text': 'body', 'title': 'T', '_query': 'q'} + with patch.object(srv, '_TOKEN_MAP', {}): + with patch.object(srv, 'run_single_query', return_value=[hit]): + resp = self.client.get('/search?q=test&mode=develop') + item = resp.json()['results'][0] + self.assertIn('filepath', item) + self.assertEqual(item['filepath'], 'ADR/001.md') + + def test_systemd_service_file_exists(self): + """brain-engine.service présent dans scripts/.""" + svc = Path(__file__).parent.parent / 'scripts' / 'brain-engine.service' + self.assertTrue(svc.exists(), f"Service absent : {svc}") + + def test_systemd_service_has_mysecrets_env(self): + """Le service charge MYSECRETS via EnvironmentFile.""" + svc = Path(__file__).parent.parent / 'scripts' / 'brain-engine.service' + content = svc.read_text() + self.assertIn('EnvironmentFile', content) + self.assertIn('MYSECRETS', content) + + def test_systemd_service_has_brain_token(self): + """Le service ne hardcode pas BRAIN_TOKEN — il vient de EnvironmentFile.""" + svc = Path(__file__).parent.parent / 'scripts' / 'brain-engine.service' + content = svc.read_text() + self.assertNotIn('BRAIN_TOKEN=', content) # jamais hardcodé dans le service + + def test_install_script_exists_and_executable(self): + """install-brain-engine.sh existe et est exécutable.""" + script = Path(__file__).parent.parent / 'scripts' / 'install-brain-engine.sh' + self.assertTrue(script.exists()) + self.assertTrue(os.access(script, os.X_OK)) + + def test_mysecrets_has_brain_token_entry(self): + """MYSECRETS contient une entrée BRAIN_TOKEN (valeur vide ou non).""" + mysecrets = Path(__file__).parent.parent / 'MYSECRETS' + self.assertTrue(mysecrets.exists(), "MYSECRETS absent") + content = mysecrets.read_text() + self.assertIn('BRAIN_TOKEN', content) + + +class TestRagScript(unittest.TestCase): + """Test existence et exécutabilité du script bash bsi-rag.sh.""" + + RAG_SCRIPT = Path(__file__).parent.parent / 'scripts' / 'bsi-rag.sh' + + def test_script_exists(self): + self.assertTrue(self.RAG_SCRIPT.exists(), f"bsi-rag.sh absent : {self.RAG_SCRIPT}") + + def test_script_executable(self): + self.assertTrue(os.access(self.RAG_SCRIPT, os.X_OK), + f"bsi-rag.sh non exécutable : {self.RAG_SCRIPT}") + + def test_script_help_passthrough(self): + """bsi-rag.sh --help passe à rag.py et sort proprement (exit 0).""" + result = subprocess.run( + ['bash', str(self.RAG_SCRIPT), '--help'], + capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0) + self.assertIn('brain-engine RAG', result.stdout) + + +# ══════════════════════════════════════════════════════════════════════════════ +# brain-db-sync.sh — tests bash (--check mode) +# ══════════════════════════════════════════════════════════════════════════════ + +import subprocess + +BRAIN_ROOT_PATH = Path(__file__).parent.parent +SYNC_SCRIPT = BRAIN_ROOT_PATH / 'scripts' / 'brain-db-sync.sh' + +class TestBrainDbSyncScript(unittest.TestCase): + + def test_script_exists_and_executable(self): + """brain-db-sync.sh existe et est exécutable.""" + self.assertTrue(SYNC_SCRIPT.exists(), f"Script absent : {SYNC_SCRIPT}") + self.assertTrue(os.access(SYNC_SCRIPT, os.X_OK), + f"Script non exécutable : {SYNC_SCRIPT}") + + def test_check_mode_ok_when_db_fresh(self): + """ + --check : exit 0 si brain.db est plus récent que le dernier commit claims. + On simule avec une DB temporaire fraîchement créée. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Mini repo git + brain.db + subprocess.run(['git', 'init', '-q'], cwd=tmpdir, check=True) + subprocess.run(['git', 'config', 'user.email', 'test@test.com'], + cwd=tmpdir, check=True) + subprocess.run(['git', 'config', 'user.name', 'Test'], cwd=tmpdir, check=True) + # brain.db créé APRÈS le dernier commit → devrait être à jour + db = Path(tmpdir) / 'brain.db' + db.write_bytes(b'SQLite format 3\x00') # header minimal + result = subprocess.run( + ['bash', str(SYNC_SCRIPT), '--check'], + cwd=tmpdir, + capture_output=True, text=True, + env={**os.environ, 'BRAIN_ROOT': tmpdir} + ) + # Exit 0 = OK, exit 2 = stale — les deux sont acceptables ici + # (dépend du timing git). L'important : pas de crash (exit 1). + self.assertNotEqual(result.returncode, 1, + f"Script crash inattendu : {result.stderr}") + + def test_check_mode_ok_on_real_brain(self): + """ + --check sur le vrai brain : exit 0 si brain.db à jour. + Note : brain-db-sync.sh calcule BRAIN_ROOT depuis dirname $0 — pas surchargeable. + Le cas DB absente (exit 2) est couvert par test manuel ou CI avec repo frais. + """ + result = subprocess.run( + ['bash', str(SYNC_SCRIPT), '--check'], + cwd=str(BRAIN_ROOT_PATH), + capture_output=True, text=True + ) + # 0 = à jour, 2 = stale — les deux OK ; 1 = crash script = fail + self.assertNotEqual(result.returncode, 1, + f"Script crash (exit 1) : {result.stderr}") + + def test_install_hooks_script_exists(self): + """install-brain-hooks.sh existe et est exécutable.""" + hooks_script = BRAIN_ROOT_PATH / 'scripts' / 'install-brain-hooks.sh' + self.assertTrue(hooks_script.exists()) + self.assertTrue(os.access(hooks_script, os.X_OK)) + + def test_install_hooks_check_mode_installed(self): + """ + install-brain-hooks.sh --check : exit 0 si le hook est installé. + Le hook est installé dans le vrai brain root (scripts/install-brain-hooks.sh + calcule BRAIN_ROOT depuis dirname $0 — ne peut pas être redirigé). + On vérifie l'état réel : le hook doit être présent en dev actif. + """ + hooks_script = BRAIN_ROOT_PATH / 'scripts' / 'install-brain-hooks.sh' + result = subprocess.run( + ['bash', str(hooks_script), '--check'], + cwd=str(BRAIN_ROOT_PATH), + capture_output=True, text=True + ) + # Exit 0 = installé, exit 1 = absent + # Les deux sont acceptables — on vérifie juste que le script ne crash pas (pas exit > 1) + self.assertLessEqual(result.returncode, 1, + f"Script crash inattendu (exit {result.returncode}): {result.stderr}") + + +# ══════════════════════════════════════════════════════════════════════════════ +# distill.py — BE-5e (summarisation 2 passes) +# ══════════════════════════════════════════════════════════════════════════════ + +def _make_messages(n: int) -> list[dict]: + """Génère n messages alternés user/assistant pour les tests.""" + roles = ['user', 'assistant'] + return [{'role': roles[i % 2], 'content': f'message {i}'} for i in range(n)] + + +class TestBuildContext(unittest.TestCase): + + def test_small_session_uses_all_messages(self): + """Session ≤ MAX_MESSAGES → tous les messages sont inclus dans le contexte.""" + msgs = _make_messages(distill.MAX_MESSAGES) + ctx = distill.build_context(msgs) + # Chaque message doit apparaître + self.assertIn('message 0', ctx) + self.assertIn(f'message {distill.MAX_MESSAGES - 1}', ctx) + + def test_large_session_truncates_to_recent(self): + """Session > MAX_MESSAGES → build_context prend les MAX_MESSAGES derniers.""" + msgs = _make_messages(distill.MAX_MESSAGES + 20) + ctx = distill.build_context(msgs) + # Les 20 premiers messages (trop anciens) ne doivent pas apparaître + self.assertNotIn('message 0', ctx) + # Les derniers doivent être présents + self.assertIn(f'message {len(msgs) - 1}', ctx) + + def test_context_respects_max_chars(self): + """build_context respecte max_chars même sur grande session.""" + msgs = _make_messages(10) + ctx = distill.build_context(msgs, max_chars=50) + self.assertLessEqual(len(ctx), 50) + + +class TestSummarize2Pass(unittest.TestCase): + + def test_splits_into_blocks_of_max_messages(self): + """summarize_2pass appelle summarize une fois par bloc + 1 fois pour la passe finale.""" + n = distill.MAX_MESSAGES * 3 # 3 blocs complets + msgs = _make_messages(n) + + call_count = [] + + def fake_summarize(context, aspect): + call_count.append(1) + return f'- résumé bloc {len(call_count)}' + + with patch.object(distill, 'summarize', side_effect=fake_summarize): + result = distill.summarize_2pass(msgs, 'decisions') + + # Pass 1 : 3 appels (un par bloc) + Pass 2 : 1 appel final = 4 total + self.assertEqual(len(call_count), 4) + self.assertIsNotNone(result) + + def test_partial_none_blocks_are_skipped(self): + """Blocs dont summarize retourne None ne cassent pas la 2e passe.""" + n = distill.MAX_MESSAGES * 2 + msgs = _make_messages(n) + + responses = [None, '- décision bloc 2'] # bloc 1 → None, bloc 2 → bullet + + def fake_summarize(context, aspect): + if responses: + return responses.pop(0) + return '- résumé final' + + with patch.object(distill, 'summarize', side_effect=fake_summarize): + result = distill.summarize_2pass(msgs, 'decisions') + + # La passe 2 est appelée car il y a au moins 1 résumé partiel non-None + self.assertIsNotNone(result) + + def test_all_none_blocks_returns_none(self): + """Si tous les blocs renvoient None, summarize_2pass retourne None.""" + n = distill.MAX_MESSAGES * 2 + msgs = _make_messages(n) + + with patch.object(distill, 'summarize', return_value=None): + result = distill.summarize_2pass(msgs, 'decisions') + + self.assertIsNone(result) + + def test_all_none_sentinel_blocks_returns_none(self): + """Si tous les blocs renvoient 'none', summarize_2pass retourne None.""" + n = distill.MAX_MESSAGES * 2 + msgs = _make_messages(n) + + with patch.object(distill, 'summarize', return_value='none'): + result = distill.summarize_2pass(msgs, 'decisions') + + self.assertIsNone(result) + + def test_single_block_still_calls_pass2(self): + """Même avec exactement MAX_MESSAGES+1 messages (2 blocs dont 1 micro), pass 2 est appelée.""" + msgs = _make_messages(distill.MAX_MESSAGES + 1) + call_args = [] + + def fake_summarize(context, aspect): + call_args.append(context[:30]) + return '- bullet test' + + with patch.object(distill, 'summarize', side_effect=fake_summarize): + result = distill.summarize_2pass(msgs, 'todos') + + # Pass 1 : 2 blocs → 2 appels ; Pass 2 : 1 appel → total 3 + self.assertEqual(len(call_args), 3) + self.assertIsNotNone(result) + + +class TestDistillSession2Pass(unittest.TestCase): + """distill_session() sélectionne 1-pass ou 2-pass selon la taille de la session.""" + + def _make_jsonl(self, n_messages: int) -> Path: + """Crée un .jsonl temporaire avec n messages.""" + import json as _json + tmp = tempfile.NamedTemporaryFile(suffix='.jsonl', delete=False, mode='w') + for i in range(n_messages): + role = 'user' if i % 2 == 0 else 'assistant' + entry = {'message': {'role': role, 'content': f'contenu message {i}'}} + tmp.write(_json.dumps(entry) + '\n') + tmp.close() + return Path(tmp.name) + + def test_small_session_uses_single_pass(self): + """Session ≤ MAX_MESSAGES → summarize() appelé directement (pas summarize_2pass).""" + jsonl = self._make_jsonl(distill.MAX_MESSAGES) + try: + with patch.object(distill, 'summarize', return_value='- décision test') as mock_sum, \ + patch.object(distill, 'summarize_2pass') as mock_2pass, \ + patch('distill.connect', return_value=None), \ + patch('distill.upsert_chunk'), \ + patch('distill.get_embedding', return_value=None): + distill.distill_session(jsonl, dry_run=True) + mock_sum.assert_called() + mock_2pass.assert_not_called() + finally: + jsonl.unlink(missing_ok=True) + + def test_large_session_uses_2pass(self): + """Session > MAX_MESSAGES → summarize_2pass() appelé, pas summarize() directement.""" + jsonl = self._make_jsonl(distill.MAX_MESSAGES + 10) + try: + with patch.object(distill, 'summarize_2pass', return_value='- décision 2pass') as mock_2pass, \ + patch.object(distill, 'summarize') as mock_sum, \ + patch('distill.connect', return_value=None), \ + patch('distill.upsert_chunk'), \ + patch('distill.get_embedding', return_value=None): + distill.distill_session(jsonl, dry_run=True) + mock_2pass.assert_called() + mock_sum.assert_not_called() + finally: + jsonl.unlink(missing_ok=True) + + def test_large_session_dry_run_produces_chunks(self): + """Grande session en dry-run → au moins 1 chunk produit par aspect non-vide.""" + jsonl = self._make_jsonl(distill.MAX_MESSAGES * 2) + try: + with patch.object(distill, 'summarize_2pass', return_value='- décision test') as mock_2pass: + n = distill.distill_session(jsonl, dry_run=True) + # 3 aspects × 1 bullet min + self.assertGreater(n, 0) + finally: + jsonl.unlink(missing_ok=True) + + +# ══════════════════════════════════════════════════════════════════════════════ + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/brain-ui/build.sh b/brain-ui/build.sh new file mode 100755 index 0000000..9550676 --- /dev/null +++ b/brain-ui/build.sh @@ -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/" diff --git a/brain-ui/index.html b/brain-ui/index.html new file mode 100644 index 0000000..bdaa1a4 --- /dev/null +++ b/brain-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Brain UI + + +
+ + + diff --git a/brain-ui/package.json b/brain-ui/package.json new file mode 100644 index 0000000..5a998dd --- /dev/null +++ b/brain-ui/package.json @@ -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" + } +} diff --git a/brain-ui/public/docs/README.md b/brain-ui/public/docs/README.md new file mode 120000 index 0000000..d5e3780 --- /dev/null +++ b/brain-ui/public/docs/README.md @@ -0,0 +1 @@ +../../../docs/README.md \ No newline at end of file diff --git a/brain-ui/public/docs/agents-brain.md b/brain-ui/public/docs/agents-brain.md new file mode 120000 index 0000000..4bb3ec6 --- /dev/null +++ b/brain-ui/public/docs/agents-brain.md @@ -0,0 +1 @@ +../../../docs/agents-brain.md \ No newline at end of file diff --git a/brain-ui/public/docs/agents-code.md b/brain-ui/public/docs/agents-code.md new file mode 120000 index 0000000..9abd389 --- /dev/null +++ b/brain-ui/public/docs/agents-code.md @@ -0,0 +1 @@ +../../../docs/agents-code.md \ No newline at end of file diff --git a/brain-ui/public/docs/agents-infra.md b/brain-ui/public/docs/agents-infra.md new file mode 120000 index 0000000..3377d26 --- /dev/null +++ b/brain-ui/public/docs/agents-infra.md @@ -0,0 +1 @@ +../../../docs/agents-infra.md \ No newline at end of file diff --git a/brain-ui/public/docs/agents.md b/brain-ui/public/docs/agents.md new file mode 120000 index 0000000..2aa1a3c --- /dev/null +++ b/brain-ui/public/docs/agents.md @@ -0,0 +1 @@ +../../../docs/agents.md \ No newline at end of file diff --git a/brain-ui/public/docs/architecture.md b/brain-ui/public/docs/architecture.md new file mode 120000 index 0000000..12790e3 --- /dev/null +++ b/brain-ui/public/docs/architecture.md @@ -0,0 +1 @@ +../../../docs/architecture.md \ No newline at end of file diff --git a/brain-ui/public/docs/getting-started.md b/brain-ui/public/docs/getting-started.md new file mode 120000 index 0000000..fc2f15d --- /dev/null +++ b/brain-ui/public/docs/getting-started.md @@ -0,0 +1 @@ +../../../docs/getting-started.md \ No newline at end of file diff --git a/brain-ui/public/docs/sessions.md b/brain-ui/public/docs/sessions.md new file mode 120000 index 0000000..52b5cf5 --- /dev/null +++ b/brain-ui/public/docs/sessions.md @@ -0,0 +1 @@ +../../../docs/sessions.md \ No newline at end of file diff --git a/brain-ui/public/docs/vue-featured.md b/brain-ui/public/docs/vue-featured.md new file mode 120000 index 0000000..c70318c --- /dev/null +++ b/brain-ui/public/docs/vue-featured.md @@ -0,0 +1 @@ +../../../docs/vue-featured.md \ No newline at end of file diff --git a/brain-ui/public/docs/vue-free.md b/brain-ui/public/docs/vue-free.md new file mode 120000 index 0000000..f4b3581 --- /dev/null +++ b/brain-ui/public/docs/vue-free.md @@ -0,0 +1 @@ +../../../docs/vue-free.md \ No newline at end of file diff --git a/brain-ui/public/docs/vue-full.md b/brain-ui/public/docs/vue-full.md new file mode 120000 index 0000000..79ff3de --- /dev/null +++ b/brain-ui/public/docs/vue-full.md @@ -0,0 +1 @@ +../../../docs/vue-full.md \ No newline at end of file diff --git a/brain-ui/public/docs/vue-pro.md b/brain-ui/public/docs/vue-pro.md new file mode 120000 index 0000000..ce36506 --- /dev/null +++ b/brain-ui/public/docs/vue-pro.md @@ -0,0 +1 @@ +../../../docs/vue-pro.md \ No newline at end of file diff --git a/brain-ui/public/docs/vue-tiers.md b/brain-ui/public/docs/vue-tiers.md new file mode 120000 index 0000000..85d6266 --- /dev/null +++ b/brain-ui/public/docs/vue-tiers.md @@ -0,0 +1 @@ +../../../docs/vue-tiers.md \ No newline at end of file diff --git a/brain-ui/public/docs/workflows.md b/brain-ui/public/docs/workflows.md new file mode 120000 index 0000000..7cfc31b --- /dev/null +++ b/brain-ui/public/docs/workflows.md @@ -0,0 +1 @@ +../../../docs/workflows.md \ No newline at end of file diff --git a/brain-ui/src/App.tsx b/brain-ui/src/App.tsx new file mode 100644 index 0000000..0aa2387 --- /dev/null +++ b/brain-ui/src/App.tsx @@ -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('workflows') + const [pendingGate, setPendingGate] = useState(null) + const [gateDrawer, setGateDrawer] = useState<{ open: boolean; workflowId: string | null; stepId: string | null }>({ + open: false, + workflowId: null, + stepId: null, + }) + const [logsProject, setLogsProject] = useState(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 ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {activeView === 'workflows' && ( + setLogsProject(wfId)} + /> + )} + {activeView === 'builder' && ( + + )} + {activeView === 'secrets' && ( + + + + )} + {activeView === 'infra' && ( + + + + )} + {activeView === 'cosmos' && ( +
+ + Chargement Cosmos... +
+ }> + + +
+ )} + {activeView === 'docs' && ( + + Chargement Docs... + + }> + + + )} + {activeView === 'workspace' && ( + + Chargement Workspace... + + }> + + + )} + + + {/* GatesDrawer — affiché si gate en attente */} + {pendingGate && ( + setPendingGate(null)} + onReject={async () => setPendingGate(null)} + onClose={() => setPendingGate(null)} + /> + )} + + {/* LogDrawer — slide-in depuis la droite */} + setLogsProject(null)} + /> + + {/* GateDrawer — approbation workflow SuperOAuth */} + setGateDrawer((prev) => ({ ...prev, open: false }))} + /> + + {/* CommandPalette — Cmd+K */} + {paletteOpen && ( + setPaletteOpen(false)} + onNavigate={(view) => { setActiveView(view as ActiveView); setPaletteOpen(false) }} + /> + )} + + ) +} + +export default function App() { + return ( + + + + ) +} diff --git a/brain-ui/src/components/CommandPalette.tsx b/brain-ui/src/components/CommandPalette.tsx new file mode 100644 index 0000000..5db71d1 --- /dev/null +++ b/brain-ui/src/components/CommandPalette.tsx @@ -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(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 ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* Input */} +
+ 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', + }} + /> +
+ + {/* Commands list */} +
+ {filtered.length === 0 && ( +
+ Aucune commande trouvée +
+ )} + {filtered.map((cmd, idx) => ( + + ))} +
+ + {/* Footer */} +
+ ↑↓ naviguer + ↵ exécuter + Esc fermer +
+
+
+ ) +} diff --git a/brain-ui/src/components/DocsView.tsx b/brain-ui/src/components/DocsView.tsx new file mode 100644 index 0000000..936b7cb --- /dev/null +++ b/brain-ui/src/components/DocsView.tsx @@ -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 = { + '\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
{children}
+ }, +} + +export default function DocsView() { + const [activeDoc, setActiveDoc] = useState('getting-started') + const [content, setContent] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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>((acc, doc) => { + const g = doc.group || 'Autres' + if (!acc[g]) acc[g] = [] + acc[g].push(doc) + return acc + }, {}) + + return ( +
+ {/* Sidebar docs */} +
+
+ + Documentation + +
+ +
+ + {/* Content */} +
+ {loading && ( +
+ Chargement... +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && ( +
+ {content} +
+ )} +
+
+ ) +} diff --git a/brain-ui/src/components/GateDrawer.tsx b/brain-ui/src/components/GateDrawer.tsx new file mode 100644 index 0000000..1a83cd7 --- /dev/null +++ b/brain-ui/src/components/GateDrawer.tsx @@ -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 */} +
+ + {/* Panel slide-in depuis la droite */} +
+ {/* Header */} +
+ {/* Titre */} + + Gate — {stepId ?? '—'} + + + {/* Badge "En attente d'approbation" */} + + En attente d'approbation + + + {/* Bouton fermer */} + +
+ + {/* Corps */} +
+ {/* Description */} +

+ Cette étape est un point de contrôle. Approuver pour continuer le workflow. +

+ + {/* Métadonnées */} + {workflowId && stepId && ( +
+
workflow {workflowId}
+
step {stepId}
+
+ )} + + {/* État "Approuvé" */} + {approved && ( +
+ Approuvé ✓ +
+ )} + + {/* Boutons */} + {!approved && ( +
+ {/* Bouton Approuver */} + + + {/* Bouton Rejeter */} + +
+ )} +
+
+ + ) +} diff --git a/brain-ui/src/components/GatesDrawer.tsx b/brain-ui/src/components/GatesDrawer.tsx new file mode 100644 index 0000000..c7b627b --- /dev/null +++ b/brain-ui/src/components/GatesDrawer.tsx @@ -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 + onReject: (action: 'abort' | 'skip') => Promise + 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 ( +
+ + Gate en attente — {stepLabel} + + + + + + + +
+ ) +} diff --git a/brain-ui/src/components/InfraRegistry.tsx b/brain-ui/src/components/InfraRegistry.tsx new file mode 100644 index 0000000..d0ca2f8 --- /dev/null +++ b/brain-ui/src/components/InfraRegistry.tsx @@ -0,0 +1,121 @@ +import { useInfra } from '../hooks/useInfra' + +const STATUS_DOT: Record = { + online: '#22c55e', + stopped: '#6b7280', + errored: '#ef4444', + unknown: '#f59e0b', +} + +const TYPE_BADGE: Record = { + 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 ( +
+ {/* Header */} +
+
+

InfraRegistry

+

+ {loading ? 'Chargement...' : `${services.length} services`} + {error && — {error}} +

+
+ +
+ + {/* Table */} +
+ {/* Header row */} +
+ Service + Type + Statut + Port + Uptime + Mem + Restarts +
+ + {/* Rows */} + {services.map((svc) => { + const dot = STATUS_DOT[svc.status] ?? '#6b7280' + const badge = TYPE_BADGE[svc.type] ?? TYPE_BADGE.info + return ( +
+ {svc.name} + + + {badge.label} + + + + + {svc.status} + + + + {svc.port ?? '—'} + + + + {formatUptime(svc.uptime)} + + + + {formatMemory(svc.memory)} + + + 10 ? '#f59e0b' : '#6b7280', fontFamily: 'monospace', fontSize: 12 }}> + {svc.restarts ?? '—'} + +
+ ) + })} + + {!loading && services.length === 0 && ( +
+ Aucun service détecté +
+ )} +
+
+ ) +} diff --git a/brain-ui/src/components/LogDrawer.tsx b/brain-ui/src/components/LogDrawer.tsx new file mode 100644 index 0000000..2e41d79 --- /dev/null +++ b/brain-ui/src/components/LogDrawer.tsx @@ -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 = { + 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(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 */} +
+ + {/* Panel slide-in */} +
+ {/* Header */} +
+ {/* Titre */} + + Logs — {project ?? '—'} + + + {/* Badge wsStatus */} + + + {wsLabel} + + + {/* Bouton Effacer */} + + + {/* Bouton fermer */} + +
+ + {/* Corps — log lines */} +
+ {logs.length === 0 ? ( +
+ Aucun log — démarrer un workflow pour voir les événements. +
+ ) : ( + logs.map((line, i) => ( +
+ + {line.ts.slice(11, 19)}{' '} + + + {line.level.toUpperCase().padEnd(5)} + + {line.msg} +
+ )) + )} +
+
+
+ + ) +} diff --git a/brain-ui/src/components/SecretsZone.tsx b/brain-ui/src/components/SecretsZone.tsx new file mode 100644 index 0000000..2df0445 --- /dev/null +++ b/brain-ui/src/components/SecretsZone.tsx @@ -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 + if (status === 'empty') + return + return +} + +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) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') { + setValue('') + setEditing(false) + setShowValue(false) + } + }, + [handleSave], + ) + + return ( +
+ {/* Row header */} +
!editing && setEditing(true)} + > + + {secret.label} + {secret.key} + + {saved ? 'sauvegardée' : statusLabel(secret.status)} + +
+ + {/* Inline edit */} + {editing && ( +
+
+
+ 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" + /> + +
+ + {secret.canGenerate && ( + + )} + + +
+ +

+ La valeur ne sera jamais affichée en clair après sauvegarde. Appuyez sur Échap pour annuler. +

+
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* Header */} + + + {/* Body */} + {open && ( +
+ {section.keys.map((secret) => ( + + ))} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+

Secrets

+

Les valeurs ne sont jamais affichées en clair

+
+ + {sections.map((section) => ( + + ))} +
+ ) +} diff --git a/brain-ui/src/components/StepNode.tsx b/brain-ui/src/components/StepNode.tsx new file mode 100644 index 0000000..e0379e9 --- /dev/null +++ b/brain-ui/src/components/StepNode.tsx @@ -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 = { + done: '#22c55e', + gate: '#f59e0b', + fail: '#ef4444', + 'in-progress': '#6366f1', + pending: '#2a2a2a', + partial: '#f97316', + blocked: '#6b7280', +} + +const STATUS_BORDER: Record = { + done: '#16a34a', + gate: '#d97706', + fail: '#dc2626', + 'in-progress': '#4f46e5', + pending: '#3f3f3f', + partial: '#ea580c', + blocked: '#4b5563', +} + +const STATUS_LABELS: Record = { + done: 'DONE', + gate: 'GATE', + fail: 'FAIL', + 'in-progress': 'IN PROGRESS', + pending: 'PENDING', + partial: 'PARTIAL', + blocked: 'BLOCKED', +} + +function StepNode({ data }: NodeProps) { + 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 ( + <> + +
+ + {label} + +
+ + + ) + } + + return ( + <> + +
+ {label} + + {STATUS_LABELS[status]} + +
+ + + ) +} + +export default memo(StepNode) diff --git a/brain-ui/src/components/TeamSelector.tsx b/brain-ui/src/components/TeamSelector.tsx new file mode 100644 index 0000000..9912b31 --- /dev/null +++ b/brain-ui/src/components/TeamSelector.tsx @@ -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(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 ( +
+ {/* Trigger */} + + + {/* Dropdown */} + {open && ( +
+ {presets.map((preset) => ( + + ))} +
+ )} +
+ ) +} diff --git a/brain-ui/src/components/TierGate.tsx b/brain-ui/src/components/TierGate.tsx new file mode 100644 index 0000000..2c93964 --- /dev/null +++ b/brain-ui/src/components/TierGate.tsx @@ -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} : ( +
+
🔒
+
Fonctionnalité non disponible
+
{feature} — tier insuffisant
+
+ ) + } + return <>{children} +} diff --git a/brain-ui/src/components/ToastProvider.tsx b/brain-ui/src/components/ToastProvider.tsx new file mode 100644 index 0000000..cbe30b6 --- /dev/null +++ b/brain-ui/src/components/ToastProvider.tsx @@ -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(null) + +// ─── Level → border color ──────────────────────────────────────────────────── + +const LEVEL_COLOR: Record = { + info: '#6366f1', + warn: '#f59e0b', + error: '#ef4444', + success: '#22c55e', +} + +const DISMISS_DELAY: Record = { + 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 ( +
+ {/* Level dot */} + + + {/* Content */} +
+ {toast.context && ( + + [{toast.context}] + + )} + {toast.message} +
+ + {/* Dismiss button */} + +
+ ) +} + +// ─── ToastProvider ──────────────────────────────────────────────────────────── + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(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 ( + + {children} + + {/* Toast container */} +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+
+ ) +} + +// ─── useToast ───────────────────────────────────────────────────────────────── + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext) + if (!ctx) { + throw new Error('useToast must be used inside ') + } + return ctx +} diff --git a/brain-ui/src/components/WorkflowBoard.tsx b/brain-ui/src/components/WorkflowBoard.tsx new file mode 100644 index 0000000..f417b01 --- /dev/null +++ b/brain-ui/src/components/WorkflowBoard.tsx @@ -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 ( +
+ { + if (onWorkflowClick && node.id.startsWith('header__')) { + onWorkflowClick(node.id.replace('header__', '')) + } + }} + fitView + fitViewOptions={{ padding: 0.3 }} + minZoom={0.3} + maxZoom={2} + proOptions={{ hideAttribution: true }} + > + + + { + const d = n.data as StepNodeData | undefined + if (!d?.status) return '#333' + const map: Record = { + done: '#22c55e', gate: '#f59e0b', fail: '#ef4444', + 'in-progress': '#6366f1', pending: '#2a2a2a', + partial: '#f97316', blocked: '#6b7280', + } + return map[d.status] ?? '#333' + }} + maskColor="#11111188" + /> + +
+ ) +} + +// ─── 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 ( + + + + ) +} diff --git a/brain-ui/src/components/WorkflowBuilder.tsx b/brain-ui/src/components/WorkflowBuilder.tsx new file mode 100644 index 0000000..7731c8f --- /dev/null +++ b/brain-ui/src/components/WorkflowBuilder.tsx @@ -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(null) + const [steps, setSteps] = useState([ + { 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 ( +
+
+

Nouveau workflow

+

+ Configure et envoie un workflow au kernel brain. +

+
+ + {/* Titre */} +
+ + { 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' }} + /> +
+ + {/* Team preset */} +
+ + +
+ + {/* Steps */} +
+ +
+ {steps.map((step, idx) => ( +
+ {/* Move */} +
+ + +
+ + {/* Type badge */} + + {step.type === 'gate' ? 'gate' : 'step'} + + + {/* Label input */} + 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 && ( + + )} +
+ ))} +
+ + {/* Add step / gate */} +
+ + +
+
+ + {/* Gate required toggle */} +
+ + + Gate humaine requise avant exécution + +
+ + {/* Submit */} +
+ + + {result && ( +
+ {result.ok ? `✓ Claim créé : ${result.claimId}` : `✗ ${result.error}`} +
+ )} +
+
+ ) +} diff --git a/brain-ui/src/components/cosmos/CosmosControls.tsx b/brain-ui/src/components/cosmos/CosmosControls.tsx new file mode 100644 index 0000000..a81e13d --- /dev/null +++ b/brain-ui/src/components/cosmos/CosmosControls.tsx @@ -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 ( +
+
+ {ZONE_OPTIONS.map((opt) => { + const isActive = activeZone === opt.id + return ( + + ) + })} +
+ +
+ + 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, + }} + /> + + + + +
+ ) +} diff --git a/brain-ui/src/components/cosmos/CosmosInfoPanel.tsx b/brain-ui/src/components/cosmos/CosmosInfoPanel.tsx new file mode 100644 index 0000000..691fe6e --- /dev/null +++ b/brain-ui/src/components/cosmos/CosmosInfoPanel.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react' +import type { CosmosPoint, ZoneKey } from '../../types' + +const ZONE_BADGE_COLORS: Record = { + 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) => void + highlightedIds: Set + 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 ( +
+ {point && ( + <> + {/* Close button */} +
+ +
+ + {/* Path */} +
+ {point.path} +
+ + {/* Zone badge */} +
+ + {point.zone} + +
+ + {/* Label */} +
+ {point.label} +
+ + {/* Separator */} +
+ + {/* Excerpt / Editor */} + {editing ? ( +
+