From 01629780f4c7fe4cbf689eb914daac052892503b Mon Sep 17 00:00:00 2001 From: Master Date: Mon, 29 Jun 2026 11:02:22 +0200 Subject: [PATCH] chore: versioning initial du systeme Context Continuity Code du systeme de memoire multi-LLM sur Trilium : - trilium_api.py : wrapper trilium-py (notes, labels, relations) - mcp_server.py : serveur MCP Starlette (19 tools, OAuth + Bearer) - api_context.py : API REST FastAPI - trilium_context.py : workflow CLI - watchdog.sh, start_*.sh : supervision et demarrage - skills, docs et ontologie associes Secrets (.env, oauth_state.json) exclus via .gitignore. --- .gitignore | 26 + KIT_travailler_avec_bastien.md | 60 +++ api_context.py | 677 ++++++++++++++++++++++++++ audit_doublons.py | 30 ++ cleanup_doublons.py | 48 ++ create_doc_api.py | 245 ++++++++++ create_readme.py | 153 ++++++ create_skill.py | 85 ++++ create_skill_structure.py | 170 +++++++ mcp_server.py | 819 ++++++++++++++++++++++++++++++++ skill_sliding_design_system.md | 469 ++++++++++++++++++ skill_synology_code.md | 238 ++++++++++ skill_synology_dev.md | 217 +++++++++ skill_trilium_api_reference.md | 180 +++++++ skill_trilium_continuity.md | 142 ++++++ start_api.sh | 7 + start_mcp.sh | 8 + synthesis_context_cintinuity.md | 187 ++++++++ test_api.py | 310 ++++++++++++ trilium_api.py | 216 +++++++++ trilium_automation_setup.md | 316 ++++++++++++ trilium_context.py | 365 ++++++++++++++ trilium_ids.json | 15 + trilium_init.py | 163 +++++++ trilium_logger.py | 64 +++ watchdog.sh | 49 ++ 26 files changed, 5259 insertions(+) create mode 100644 .gitignore create mode 100644 KIT_travailler_avec_bastien.md create mode 100644 api_context.py create mode 100644 audit_doublons.py create mode 100644 cleanup_doublons.py create mode 100644 create_doc_api.py create mode 100644 create_readme.py create mode 100644 create_skill.py create mode 100644 create_skill_structure.py create mode 100644 mcp_server.py create mode 100644 skill_sliding_design_system.md create mode 100644 skill_synology_code.md create mode 100644 skill_synology_dev.md create mode 100644 skill_trilium_api_reference.md create mode 100644 skill_trilium_continuity.md create mode 100644 start_api.sh create mode 100644 start_mcp.sh create mode 100644 synthesis_context_cintinuity.md create mode 100644 test_api.py create mode 100644 trilium_api.py create mode 100644 trilium_automation_setup.md create mode 100644 trilium_context.py create mode 100644 trilium_ids.json create mode 100644 trilium_init.py create mode 100644 trilium_logger.py create mode 100644 watchdog.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..051f276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Secrets — JAMAIS versionner +.env +oauth_state.json +ssh-keys/ + +# Environnement Python +venv/ +__pycache__/ +*.pyc +.pytest_cache/ + +# Backups et patches jetables +*.bak +*.bak_* +patch_*.py + +# Logs et PID +*.log +*.pid + +# Dépôt git imbriqué (test) +mon-depot/ + +# Fichiers d'IDs runtime (générés, non sensibles mais spécifiques à l'instance) +entites_ids.json +entites_sliding.json diff --git a/KIT_travailler_avec_bastien.md b/KIT_travailler_avec_bastien.md new file mode 100644 index 0000000..c6db1bb --- /dev/null +++ b/KIT_travailler_avec_bastien.md @@ -0,0 +1,60 @@ +# KIT — Travailler avec Bastien + +Point d'entrée du kit de skills réutilisables. À lire en premier au début +d'une session. Indique quel skill activer selon ce que tu fais. + +Bastien est Head of Data Governance chez Pernod Ricard. Il développe des +outils sur son infrastructure auto-hébergée (Synology « GrosseBertha »), avec +une mémoire de projet centralisée dans Trilium, partagée entre plusieurs LLMs +(Claude et Le Chat). Ces skills encodent sa façon de travailler pour qu'elle +soit portable d'une conversation et d'un projet à l'autre. + +--- + +## Les skills du kit + +| Skill | Quand l'activer | +|---|---| +| **skill_trilium_continuity.md** | **Toujours, sur tout projet suivi.** Le réflexe de mémoire : lire le contexte en début de session, capitaliser décisions/tests/contraintes au fil de l'eau, clôturer en fin. C'est un comportement attendu, pas optionnel. | +| **skill_synology_dev.md** | Dès qu'on touche au code sur GrosseBertha : patch Python, conventions SSH, sécurité des YAML, commits Forgejo. | +| **skill_trilium_api_reference.md** | Référence technique — à sortir pour débugger ou étendre le système Trilium (endpoints, wrapper, MCP, structure des notes). Pas nécessaire pour l'usage quotidien. | +| **skill_sliding_design_system.md** | Uniquement pour produire une présentation PowerPoint au format Pernod Ricard (YAML pour le moteur de rendu). Indépendant du reste. | + +--- + +## Trois couches, du plus large au plus spécifique + +**Couche universelle — toute conversation, tout projet.** +`skill_trilium_continuity` + `skill_synology_dev`. Comment travailler avec +Bastien et son infrastructure : tenir la mémoire à jour, et développer sur le +Synology dans les règles. + +**Couche semi-spécifique — toute production de présentation.** +`skill_sliding_design_system`. Utile dès qu'il s'agit de slides au format PR, +quel que soit le contexte. + +**Couche spécifique à un projet — vit dans Trilium, pas dans un fichier.** +L'état réel d'un projet (décisions actives, historique, backlog) est dans +Trilium sous le label `projet=`. On ne le duplique pas dans un +document : on le **lit** en début de session via le réflexe de continuité. + +--- + +## Ordre de lecture en début de session + +1. Lire **skill_trilium_continuity** et récupérer le briefing de reprise du + projet concerné (décisions actives, historique, backlog). +2. Selon la tâche, activer **skill_synology_dev** (code) et/ou + **skill_sliding_design_system** (présentation). +3. Garder **skill_trilium_api_reference** sous la main pour le débogage. +4. Tout au long : capitaliser proactivement dans Trilium aux moments clés. + +--- + +## Note sur l'accès Trilium côté Claude + +Le serveur MCP Trilium fonctionne pour Le Chat (Mistral) mais pas pour Claude : +il utilise un Bearer token statique, alors que les connecteurs Claude exigent +OAuth. En attendant l'ajout d'une couche OAuth, l'accès Trilium côté Claude se +fait **par relais** — Claude propose la commande ou le curl, Bastien l'exécute +et colle le retour. Voir skill_trilium_continuity pour le détail. diff --git a/api_context.py b/api_context.py new file mode 100644 index 0000000..b32ed32 --- /dev/null +++ b/api_context.py @@ -0,0 +1,677 @@ +""" +api_context.py — API FastAPI pour Context Continuity +Expose les objets Trilium (Projet, Backlog, Decision, Historique, +Conversation, Glossaire, ContexteReprise) via REST. + +Usage : + uvicorn api_context:app --host 127.0.0.1 --port 5000 + Doc : http://localhost:5000/api/docs +""" +import os +import re +from typing import Optional, List +from datetime import datetime +from dotenv import load_dotenv +from fastapi import FastAPI, Depends, HTTPException, Header +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +load_dotenv() + +API_KEY_CLAUDE = os.getenv("API_KEY_CLAUDE", "") +API_KEY_LECHAT = os.getenv("API_KEY_LECHAT", "") +VALID_KEYS = {API_KEY_CLAUDE: "Claude", API_KEY_LECHAT: "LeChat"} + +app = FastAPI( + title="Context Continuity API", + description="API REST pour la gestion du contexte multi-LLM via Trilium", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +def get_llm(authorization: str = Header(...)) -> str: + """Valide la clé API et retourne le nom du LLM appelant.""" + key = authorization.replace("Bearer ", "").strip() + if key not in VALID_KEYS: + raise HTTPException(status_code=401, detail="Clé API invalide") + return VALID_KEYS[key] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def strip_html(text: str) -> str: + text = re.sub(r"", "\n", text) + text = re.sub(r"<[^>]+>", "", text) + return text.replace("&", "&").replace("<", "<").replace(">", ">").strip() + +def note_to_dict(note: dict, with_content: bool = False) -> dict: + from trilium_api import get_note, get_note_content, get_label_value + nid = note.get("noteId", note.get("id", "")) + detail = get_note(nid) + attrs = detail.get("attributes", []) + labels = {a["name"]: a["value"] for a in attrs if a.get("type") == "label"} + result = { + "id": nid, + "title": detail.get("title", ""), + "labels": labels, + "created": detail.get("utcDateCreated", ""), + "modified": detail.get("utcDateModified", ""), + } + if with_content: + result["content"] = strip_html(get_note_content(nid)) + return result + +# --------------------------------------------------------------------------- +# Modèles Pydantic +# --------------------------------------------------------------------------- + +class ProjetCreate(BaseModel): + nom: str = Field(..., description="Nom du projet (sans espaces)") + objectif: str = Field(..., description="Objectif en 1-2 phrases") + stack: Optional[str] = Field(None, description="Stack technique") + llm_repartition: Optional[str] = Field(None, description="Répartition LLMs") + +class ProjetPatch(BaseModel): + statut: Optional[str] = Field(None, pattern="^(actif|en-pause|archive)$") + objectif: Optional[str] = None + stack: Optional[str] = None + +class BacklogCreate(BaseModel): + projet: str + titre: str + priorite: str = Field("moyenne", pattern="^(haute|moyenne|basse)$") + +class BacklogPatch(BaseModel): + statut: Optional[str] = Field(None, pattern="^(a faire|en cours|bloque|fait|abandonne)$") + priorite: Optional[str] = Field(None, pattern="^(haute|moyenne|basse)$") + +class DecisionCreate(BaseModel): + projet: str + enonce: str + justification: Optional[str] = None + llm: Optional[str] = None + +class DecisionPatch(BaseModel): + statut: Optional[str] = Field(None, pattern="^(active|revisee|annulee)$") + justification: Optional[str] = None + +class HistoriqueCreate(BaseModel): + projet: str + enonce: str + type_historique: str = Field(..., alias="type", + pattern="^(Fait etabli|Test effectue|Hypothese invalidee|Contrainte decouverte)$") + detail: Optional[str] = None + + model_config = {"populate_by_name": True} + +class HistoriquePatch(BaseModel): + encore_valide: Optional[bool] = None + detail: Optional[str] = None + +class ConversationCreate(BaseModel): + projet: str + titre: str + llm: str = Field(..., pattern="^(Claude Sonnet|Claude Opus|Le Chat Large|Le Chat Medium)$") + +class ConversationPatch(BaseModel): + synthese_cloture: Optional[str] = Field(None, max_length=500) + statut: Optional[str] = Field(None, pattern="^(en-cours|clos)$") + +class GlossaireCreate(BaseModel): + projet: str + terme: str + definition: str + synonymes_eviter: Optional[str] = None + +class GlossairePatch(BaseModel): + definition: Optional[str] = None + synonymes_eviter: Optional[str] = None + +class SkillCreate(BaseModel): + titre: str + contenu: str + portee: str = "projet" # "universel" | "reference-technique" | "projet" | autre libellé libre + projet: Optional[str] = None # optionnel si portee="universel" + +class SkillPatch(BaseModel): + contenu: Optional[str] = None + portee: Optional[str] = None + +# --------------------------------------------------------------------------- +# Routes — Health +# --------------------------------------------------------------------------- + +@app.get("/api/health", tags=["Système"]) +def health(): + return {"status": "ok", "version": "1.0.0"} + +# --------------------------------------------------------------------------- +# Routes — Projets +# --------------------------------------------------------------------------- + +@app.get("/api/projets", tags=["Projets"]) +def list_projets(llm: str = Depends(get_llm)): + from trilium_api import search_by_label + notes = search_by_label("type", "projet") + # Exclure la note racine Context Continuity + notes = [n for n in notes if n.get("title") != "Context Continuity"] + return [note_to_dict(n) for n in notes] + +@app.get("/api/projets/{nom}", tags=["Projets"]) +def get_projet(nom: str, llm: str = Depends(get_llm)): + from trilium_api import search_by_label + notes = search_by_label("projet", nom) + projets = [n for n in notes if n.get("title", "").replace(" ", "") == nom or + any(a.get("name") == "projet" and a.get("value") == nom + for a in n.get("attributes", []))] + # Chercher la note projet spécifique + from trilium_api import search_notes, get_note, get_label_value + all_projets = search_by_label("type", "projet") + for p in all_projets: + if get_label_value(p["noteId"], "projet") == nom: + return note_to_dict(p, with_content=True) + raise HTTPException(status_code=404, detail=f"Projet '{nom}' introuvable") + +@app.post("/api/projets", tags=["Projets"], status_code=201) +def create_projet(body: ProjetCreate, llm: str = Depends(get_llm)): + import json + from trilium_api import create_note, get_note_id, set_label, update_note_content + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + res = create_note(ids["Projets"], body.nom) + nid = get_note_id(res) + set_label(nid, "type", "projet") + set_label(nid, "projet", body.nom) + set_label(nid, "statut", "actif") + content = f"

{body.nom}

Objectif : {body.objectif}

" + if body.stack: + content += f"

Stack : {body.stack}

" + if body.llm_repartition: + content += f"

LLM : {body.llm_repartition}

" + update_note_content(nid, content) + return {"id": nid, "nom": body.nom, "created_by": llm} + +@app.patch("/api/projets/{note_id}", tags=["Projets"]) +def patch_projet(note_id: str, body: ProjetPatch, llm: str = Depends(get_llm)): + from trilium_api import get_note, set_label, update_note_content, get_note_content + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Projet introuvable") + if body.statut: + set_label(note_id, "statut", body.statut) + if body.objectif or body.stack: + content = get_note_content(note_id) + if body.objectif: + content += f"

Objectif mis à jour : {body.objectif}

" + if body.stack: + content += f"

Stack : {body.stack}

" + update_note_content(note_id, content) + return {"id": note_id, "updated_by": llm} + +# --------------------------------------------------------------------------- +# Routes — Backlog +# --------------------------------------------------------------------------- + +@app.get("/api/backlog/{projet}", tags=["Backlog"]) +def list_backlog(projet: str, llm: str = Depends(get_llm)): + from trilium_api import search_by_label, get_label_value + notes = search_by_label("type", "backlogItem") + actifs = [n for n in notes + if get_label_value(n["noteId"], "projet") == projet + and get_label_value(n["noteId"], "statut") not in ("fait", "abandonne")] + prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2} + actifs.sort(key=lambda n: prio_ordre.get( + get_label_value(n["noteId"], "priorite") or "basse", 99)) + return [note_to_dict(n) for n in actifs] + +@app.post("/api/backlog", tags=["Backlog"], status_code=201) +def create_backlog(body: BacklogCreate, llm: str = Depends(get_llm)): + import json + from trilium_api import create_note, get_note_id, set_label + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + res = create_note(ids["Backlog"], body.titre) + nid = get_note_id(res) + set_label(nid, "type", "backlogItem") + set_label(nid, "projet", body.projet) + set_label(nid, "priorite", body.priorite) + set_label(nid, "statut", "a faire") + return {"id": nid, "titre": body.titre, "created_by": llm} + +@app.patch("/api/backlog/{note_id}", tags=["Backlog"]) +def patch_backlog(note_id: str, body: BacklogPatch, llm: str = Depends(get_llm)): + from trilium_api import get_note, set_label + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Backlog item introuvable") + if body.statut: + set_label(note_id, "statut", body.statut) + if body.priorite: + set_label(note_id, "priorite", body.priorite) + return {"id": note_id, "updated_by": llm} + +# --------------------------------------------------------------------------- +# Routes — Décisions +# --------------------------------------------------------------------------- + +@app.get("/api/decisions/{projet}", tags=["Décisions"]) +def list_decisions(projet: str, llm: str = Depends(get_llm)): + from trilium_api import search_by_label, get_label_value + notes = search_by_label("type", "decision") + actives = [n for n in notes + if get_label_value(n["noteId"], "projet") == projet + and get_label_value(n["noteId"], "statut") == "active"] + return [note_to_dict(n) for n in actives] + +@app.post("/api/decisions", tags=["Décisions"], status_code=201) +def create_decision(body: DecisionCreate, llm: str = Depends(get_llm)): + import json + from trilium_api import create_note, get_note_id, set_label + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + res = create_note(ids["Decisions"], body.enonce, + content=f"

Justification : {body.justification or '-'}

") + nid = get_note_id(res) + set_label(nid, "type", "decision") + set_label(nid, "projet", body.projet) + set_label(nid, "statut", "active") + set_label(nid, "llm", body.llm or llm) + return {"id": nid, "enonce": body.enonce, "created_by": llm} + +@app.patch("/api/decisions/{note_id}", tags=["Décisions"]) +def patch_decision(note_id: str, body: DecisionPatch, llm: str = Depends(get_llm)): + from trilium_api import get_note, set_label, get_note_content, update_note_content + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Décision introuvable") + if body.statut: + set_label(note_id, "statut", body.statut) + if body.justification: + content = get_note_content(note_id) + content += f"

Révision ({llm}) : {body.justification}

" + update_note_content(note_id, content) + return {"id": note_id, "updated_by": llm} + +# --------------------------------------------------------------------------- +# Routes — Historique +# --------------------------------------------------------------------------- + +@app.get("/api/historique/{projet}", tags=["Historique"]) +def list_historique(projet: str, llm: str = Depends(get_llm)): + from trilium_api import search_by_label, get_label_value + notes = search_by_label("type", "historiqueItem") + valides = [n for n in notes + if get_label_value(n["noteId"], "projet") == projet + and get_label_value(n["noteId"], "encoreValide") == "true"] + return [note_to_dict(n) for n in valides] + +@app.post("/api/historique", tags=["Historique"], status_code=201) +def create_historique(body: HistoriqueCreate, llm: str = Depends(get_llm)): + import json + from trilium_api import create_note, get_note_id, set_label + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + type_h = body.type_historique + res = create_note(ids["Historique"], body.enonce, + content=f"

Type : {type_h}

" + f"

Détail : {body.detail or '-'}

") + nid = get_note_id(res) + set_label(nid, "type", "historiqueItem") + set_label(nid, "projet", body.projet) + set_label(nid, "typeHistorique", type_h) + set_label(nid, "encoreValide", "true") + return {"id": nid, "enonce": body.enonce, "created_by": llm} + +@app.patch("/api/historique/{note_id}", tags=["Historique"]) +def patch_historique(note_id: str, body: HistoriquePatch, llm: str = Depends(get_llm)): + from trilium_api import get_note, set_label, get_note_content, update_note_content + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Entrée historique introuvable") + if body.encore_valide is not None: + set_label(note_id, "encoreValide", "true" if body.encore_valide else "false") + if body.detail: + content = get_note_content(note_id) + content += f"

Mise à jour ({llm}) : {body.detail}

" + update_note_content(note_id, content) + return {"id": note_id, "updated_by": llm} + +# --------------------------------------------------------------------------- +# Routes — Conversations +# --------------------------------------------------------------------------- + +@app.get("/api/conversations/{projet}", tags=["Conversations"]) +def list_conversations(projet: str, llm: str = Depends(get_llm)): + from trilium_api import search_by_label, get_label_value + notes = search_by_label("type", "conversation") + conv = [n for n in notes + if get_label_value(n["noteId"], "projet") == projet] + return [note_to_dict(n) for n in conv] + +@app.get("/api/conversations/{projet}/{note_id}", tags=["Conversations"]) +def get_conversation(projet: str, note_id: str, llm: str = Depends(get_llm)): + from trilium_api import get_note, get_label_value + try: + note = get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Conversation introuvable") + return note_to_dict({"noteId": note_id}, with_content=True) + +@app.post("/api/conversations", tags=["Conversations"], status_code=201) +def create_conversation(body: ConversationCreate, llm: str = Depends(get_llm)): + import json + from trilium_api import create_note, get_note_id, set_label + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + date = datetime.now().strftime("%Y-%m-%d %H:%M") + titre = f"[{body.llm}] {date} — {body.titre}" + content = (f"

{titre}

" + f"" + f"" + f"" + f"" + f"" + f"" + f"
Projet{body.projet}
LLM{body.llm}
Date{date}
Synthèse de clôtureÀ remplir en fin de session
") + res = create_note(ids["Conversations"], titre, content=content) + nid = get_note_id(res) + set_label(nid, "type", "conversation") + set_label(nid, "projet", body.projet) + set_label(nid, "llm", body.llm) + set_label(nid, "statut", "en-cours") + set_label(nid, "date", date) + return {"id": nid, "titre": titre, "created_by": llm} + +@app.patch("/api/conversations/{note_id}", tags=["Conversations"]) +def patch_conversation(note_id: str, body: ConversationPatch, llm: str = Depends(get_llm)): + from trilium_api import get_note, set_label, get_note_content, update_note_content + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Conversation introuvable") + if body.synthese_cloture: + content = get_note_content(note_id) + content = content.replace( + "À remplir en fin de session", + body.synthese_cloture.replace("<", "<").replace(">", ">") + ) + if "À remplir en fin de session" not in get_note_content(note_id): + content += f"

Synthèse

{body.synthese_cloture}

" + update_note_content(note_id, content) + set_label(note_id, "syntheseCloture", body.synthese_cloture[:200]) + set_label(note_id, "statut", "clos") + if body.statut: + set_label(note_id, "statut", body.statut) + return {"id": note_id, "updated_by": llm} + +# --------------------------------------------------------------------------- +# Routes — Glossaire +# --------------------------------------------------------------------------- + +@app.get("/api/glossaire/{projet}", tags=["Glossaire"]) +def list_glossaire(projet: str, llm: str = Depends(get_llm)): + from trilium_api import search_by_label, get_label_value + notes = search_by_label("type", "termeGlossaire") + termes = [n for n in notes + if get_label_value(n["noteId"], "projet") == projet] + return [note_to_dict(n, with_content=True) for n in termes] + +@app.post("/api/glossaire", tags=["Glossaire"], status_code=201) +def create_glossaire(body: GlossaireCreate, llm: str = Depends(get_llm)): + import json + from trilium_api import create_note, get_note_id, set_label + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + content = f"

Définition : {body.definition}

" + if body.synonymes_eviter: + content += f"

Synonymes à éviter : {body.synonymes_eviter}

" + res = create_note(ids["Glossaire"], body.terme, content=content) + nid = get_note_id(res) + set_label(nid, "type", "termeGlossaire") + set_label(nid, "projet", body.projet) + set_label(nid, "definition", body.definition) + return {"id": nid, "terme": body.terme, "created_by": llm} + +@app.patch("/api/glossaire/{note_id}", tags=["Glossaire"]) +def patch_glossaire(note_id: str, body: GlossairePatch, llm: str = Depends(get_llm)): + from trilium_api import get_note, set_label, get_note_content, update_note_content + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Terme introuvable") + if body.definition: + set_label(note_id, "definition", body.definition) + content = get_note_content(note_id) + content += f"

Définition mise à jour ({llm}) : {body.definition}

" + update_note_content(note_id, content) + if body.synonymes_eviter: + content = get_note_content(note_id) + content += f"

Synonymes à éviter : {body.synonymes_eviter}

" + update_note_content(note_id, content) + return {"id": note_id, "updated_by": llm} + +# --------------------------------------------------------------------------- +# Routes — Contexte Reprise +# --------------------------------------------------------------------------- + +@app.get("/api/contexte/{projet}", tags=["Contexte Reprise"]) +def get_contexte(projet: str, llm_cible: str = "LLM", + llm: str = Depends(get_llm)): + """Génère et retourne le briefing de reprise en texte.""" + from trilium_api import (search_by_label, get_label_value, + get_note_content, create_note, get_note_id, + set_label, get_note) + import json, re as re2 + + decisions = search_by_label("type", "decision") + decisions = [d for d in decisions + if get_label_value(d["noteId"], "projet") == projet + and get_label_value(d["noteId"], "statut") == "active"] + + historique = search_by_label("type", "historiqueItem") + historique = [h for h in historique + if get_label_value(h["noteId"], "projet") == projet + and get_label_value(h["noteId"], "encoreValide") == "true"] + + backlog = search_by_label("type", "backlogItem") + backlog = [b for b in backlog + if get_label_value(b["noteId"], "projet") == projet + and get_label_value(b["noteId"], "statut") not in ("fait", "abandonne")] + + glossaire = search_by_label("type", "termeGlossaire") + glossaire = [g for g in glossaire + if get_label_value(g["noteId"], "projet") == projet] + + date = datetime.now().strftime("%d/%m/%Y") + lines = [ + f"# REPRISE DE CONTEXTE — {projet} — {date}", + f"**LLM cible : {llm_cible}**", "", + ] + + if decisions: + lines += ["## Décisions actives (ne pas remettre en question)"] + for d in decisions: + lines.append(f"- {d.get('title', '?')}") + lines.append("") + + if historique: + lines += ["## Historique — déjà testé / établi"] + for h in historique: + type_h = get_label_value(h["noteId"], "typeHistorique") or "" + lines.append(f"- [{type_h}] {h.get('title', '?')}") + lines.append("") + + if glossaire: + lines += ["## Glossaire"] + for g in glossaire: + defn = get_label_value(g["noteId"], "definition") or "(voir note)" + lines.append(f"- **{g.get('title','?')}** : {defn}") + lines.append("") + + if backlog: + lines += ["## Backlog actif"] + prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2} + backlog.sort(key=lambda b: prio_ordre.get( + get_label_value(b["noteId"], "priorite") or "basse", 99)) + for b in backlog: + prio = get_label_value(b["noteId"], "priorite") or "?" + statut = get_label_value(b["noteId"], "statut") or "?" + lines.append(f"- [{statut}] [{prio}] {b.get('title','?')}") + lines.append("") + + lines += [ + "---", + "Confirme en 3 lignes : (a) objectif (b) prochaine action (c) incertitude", + ] + + briefing = "\n".join(lines) + tokens = max(1, int(len(briefing) / 4)) + return {"projet": projet, "tokens": tokens, "briefing": briefing} + +@app.get("/api/contexte/{projet}/last", tags=["Contexte Reprise"]) +def get_last_contexte(projet: str, llm: str = Depends(get_llm)): + """Retourne le dernier contexte de reprise sauvegardé.""" + from trilium_api import search_by_label, get_label_value, get_note_content + notes = search_by_label("type", "contexteReprise") + notes = [n for n in notes + if get_label_value(n["noteId"], "projet") == projet] + if not notes: + raise HTTPException(status_code=404, + detail=f"Aucun contexte de reprise pour '{projet}'") + notes.sort(key=lambda n: int(get_label_value(n["noteId"], "version") or 0), + reverse=True) + derniere = notes[0] + nid = derniere["noteId"] + content = get_note_content(nid) + texte = re.sub(r"", "\n", content) + texte = re.sub(r"<[^>]+>", "", texte) + texte = texte.replace("&", "&").strip() + return { + "id": nid, + "titre": derniere.get("title", ""), + "version": get_label_value(nid, "version"), + "briefing": texte, + } + +# --------------------------------------------------------------------------- +# Routes — Skills +# --------------------------------------------------------------------------- +# Les skills documentent un savoir-faire réutilisable (dev Synology, charte +# design, réflexe Trilium...). Contrairement aux autres objets, le label +# `projet` est optionnel : un skill peut être "universel" (portee=universel) +# et ne dépendre d'aucun projet précis. + +@app.get("/api/skills", tags=["Skills"]) +def list_skills(projet: Optional[str] = None, portee: Optional[str] = None, + llm: str = Depends(get_llm)): + """Liste les skills. Filtre optionnel par projet et/ou portee. + Sans filtre projet, retourne aussi les skills universels.""" + from trilium_api import search_by_label, get_label_value + notes = search_by_label("type", "skill") + result = [] + for n in notes: + nid = n["noteId"] + note_projet = get_label_value(nid, "projet") + note_portee = get_label_value(nid, "portee") + if projet and note_projet != projet and note_portee != "universel": + continue + if portee and note_portee != portee: + continue + result.append(note_to_dict(n, with_content=True)) + return result + +@app.get("/api/skills/{note_id}", tags=["Skills"]) +def get_skill(note_id: str, llm: str = Depends(get_llm)): + """Récupère un skill par son id, contenu inclus.""" + from trilium_api import get_note + try: + note = get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Skill introuvable") + return note_to_dict({"noteId": note_id, **note}, with_content=True) + +@app.post("/api/skills", tags=["Skills"], status_code=201) +def create_skill(body: SkillCreate, llm: str = Depends(get_llm)): + """Crée un skill. portee='universel' pour un skill sans projet rattaché.""" + import json + from trilium_api import create_note, get_note_id, set_label, find_note_by_title + with open(os.path.expanduser("~/App/Context_continuity/trilium_ids.json")) as f: + ids = json.load(f) + if body.portee != "universel" and not body.projet: + raise HTTPException(status_code=400, + detail="projet requis sauf si portee='universel'") + existant = find_note_by_title(body.titre, ids["Skills"]) + if existant: + raise HTTPException(status_code=409, + detail=f"Un skill '{body.titre}' existe déjà ({existant})") + content = f"
{body.contenu}
" + res = create_note(ids["Skills"], body.titre, content=content) + nid = get_note_id(res) + set_label(nid, "type", "skill") + set_label(nid, "portee", body.portee) + if body.projet: + set_label(nid, "projet", body.projet) + return {"id": nid, "titre": body.titre, "created_by": llm} + +@app.patch("/api/skills/{note_id}", tags=["Skills"]) +def patch_skill(note_id: str, body: SkillPatch, llm: str = Depends(get_llm)): + """Met à jour le contenu et/ou la portee d'un skill existant.""" + from trilium_api import get_note, set_label, update_note_content + try: + get_note(note_id) + except Exception: + raise HTTPException(status_code=404, detail="Skill introuvable") + if body.contenu is not None: + update_note_content(note_id, f"
{body.contenu}
") + if body.portee is not None: + set_label(note_id, "portee", body.portee) + return {"id": note_id, "updated_by": llm} + + +@app.delete("/api/notes/{note_id}", tags=["Système"]) +def delete_note_endpoint(note_id: str, llm: str = Depends(get_llm)): + """Supprime une note du systeme. Garde-fou : refuse les notes hors systeme.""" + from trilium_api import delete_note_safe + ok, message = delete_note_safe(note_id) + if not ok: + raise HTTPException(status_code=400, detail=message) + return {"deleted": True, "message": message, "by": llm} + + +class MoveNoteBody(BaseModel): + ancien_parent_id: str + nouveau_parent_id: str + +@app.post("/api/notes/{note_id}/move", tags=["Système"]) +def move_note_endpoint(note_id: str, body: MoveNoteBody, llm: str = Depends(get_llm)): + """Deplace une note d un parent vers un autre. Gere les clones.""" + from trilium_api import move_note_safe + ok, message = move_note_safe(note_id, body.ancien_parent_id, body.nouveau_parent_id) + if not ok: + raise HTTPException(status_code=400, detail=message) + return {"moved": True, "message": message, "by": llm} + + +class AddRelationBody(BaseModel): + nom: str + cible_id: str + +@app.post("/api/notes/{source_id}/relation", tags=["Système"]) +def add_relation_endpoint(source_id: str, body: AddRelationBody, llm: str = Depends(get_llm)): + """Cree une relation typee entre deux notes. Garde-fou : nom dans l ontologie.""" + from trilium_api import add_relation_safe + ok, message = add_relation_safe(source_id, body.nom, body.cible_id) + if not ok: + raise HTTPException(status_code=400, detail=message) + return {"created": True, "message": message, "by": llm} diff --git a/audit_doublons.py b/audit_doublons.py new file mode 100644 index 0000000..a0932fa --- /dev/null +++ b/audit_doublons.py @@ -0,0 +1,30 @@ +"""Audit lecture seule : detecte TOUS les labels en doublon, quel que soit le nom.""" +from trilium_api import search_by_label, get_note + +types_notes = ["backlogItem", "decision", "historiqueItem", "conversation", + "termeGlossaire", "projet", "contexteReprise", "skill", "documentation"] + +notes_traitees = set() +total_doublons = 0 +notes_affectees = 0 + +for tn in types_notes: + for n in search_by_label("type", tn): + nid = n["noteId"] + if nid in notes_traitees: + continue + notes_traitees.add(nid) + note = get_note(nid) + attrs = [a for a in note.get("attributes", []) if a.get("type") == "label"] + par_nom = {} + for a in attrs: + par_nom.setdefault(a.get("name"), []).append(a) + doublons_note = {nom: len(lst) for nom, lst in par_nom.items() if len(lst) > 1} + if doublons_note: + notes_affectees += 1 + titre = note.get("title", "?")[:40] + print(f"{nid} ({titre}) : {doublons_note}") + total_doublons += sum(v - 1 for v in doublons_note.values()) + +print(f"\n{notes_affectees} notes affectees, {total_doublons} labels en double au total.") +print("Noms de labels concernes a verifier si absents de la liste de nettoyage :") diff --git a/cleanup_doublons.py b/cleanup_doublons.py new file mode 100644 index 0000000..932b38a --- /dev/null +++ b/cleanup_doublons.py @@ -0,0 +1,48 @@ +"""Nettoyage one-shot des labels en doublon sur les notes du systeme.""" +from trilium_api import _ea, search_by_label, get_note + +ea = _ea() +LABELS_A_DEDOUBLONNER = ["statut", "priorite", "encoreValide", "type", "projet"] + +# On parcourt tous les types de notes du systeme +types_notes = ["backlogItem", "decision", "historiqueItem", "conversation", + "termeGlossaire", "projet", "contexteReprise", "skill"] + +notes_traitees = set() +total_supprimes = 0 + +for tn in types_notes: + for n in search_by_label("type", tn): + nid = n["noteId"] + if nid in notes_traitees: + continue + notes_traitees.add(nid) + note = get_note(nid) + attrs = [a for a in note.get("attributes", []) if a.get("type") == "label"] + # Grouper par nom de label + par_nom = {} + for a in attrs: + par_nom.setdefault(a.get("name"), []).append(a) + for name, lst in par_nom.items(): + if len(lst) <= 1: + continue + if name not in LABELS_A_DEDOUBLONNER: + continue + # Valeur de reference = celle du dernier label (plus recent) + valeur_finale = lst[-1].get("value", "") + garde = lst[0] + # Patcher le premier avec la valeur finale + try: + ea.patch_attribute(attributeId=garde.get("attributeId"), value=valeur_finale) + except Exception as e: + print(" patch echoue sur", nid, name, ":", e) + # Supprimer tous les autres + for doublon in lst[1:]: + try: + ea.delete_attribute(attributeId=doublon.get("attributeId")) + total_supprimes += 1 + except Exception as e: + print(" delete echoue :", e) + print(f"{nid} : {name} dedoublonne ({len(lst)} -> 1, valeur={valeur_finale})") + +print(f"\nTermine. {total_supprimes} labels en double supprimes.") diff --git a/create_doc_api.py b/create_doc_api.py new file mode 100644 index 0000000..38c411a --- /dev/null +++ b/create_doc_api.py @@ -0,0 +1,245 @@ +import json, os +from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content + +with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f: + ids = json.load(f) + +root_id = ids['root'] + +# ── Dossier Documentation ────────────────────────────────────────────────── +doc_id = find_note_by_title('Documentation', root_id) +if not doc_id: + res = create_note(root_id, 'Documentation', note_type='book') + doc_id = get_note_id(res) + set_label(doc_id, 'type', 'container') + print('Dossier Documentation cree:', doc_id) +else: + print('Dossier Documentation existant:', doc_id) + +# ── Note index Documentation ─────────────────────────────────────────────── +res = create_note(doc_id, 'API Context Continuity - Vue densemble') +nid = get_note_id(res) +set_label(nid, 'type', 'documentation') +set_label(nid, 'projet', 'ContextContinuity') +update_note_content(nid, + '

API Context Continuity

' + '

Base URL externe : https://api-trilium.bertha-cloud.fr
' + 'Base URL locale : http://localhost:8765
' + 'Doc Swagger : https://api-trilium.bertha-cloud.fr/api/docs
' + 'Auth : Header Authorization: <API_KEY>

' + '

Ressources disponibles

' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
RessourceEndpointsNote Trilium
ProjetsGET /api/projets, GET /api/projets/{nom}, POST, PATCHDocumentation > Projets
BacklogGET /api/backlog/{projet}, POST /api/backlog, PATCHDocumentation > Backlog
DecisionsGET /api/decisions/{projet}, POST, PATCHDocumentation > Decisions
HistoriqueGET /api/historique/{projet}, POST, PATCHDocumentation > Historique
ConversationsGET /api/conversations/{projet}, POST, PATCHDocumentation > Conversations
GlossaireGET /api/glossaire/{projet}, POST, PATCHDocumentation > Glossaire
Contexte RepriseGET /api/contexte/{projet}, GET /api/contexte/{projet}/lastDocumentation > Contexte Reprise
' + '

Codes de retour

' + '' + '' + '' + '' + '' + '' + '' + '
CodeSignification
200OK
201Cree avec succes
401Cle API invalide
404Ressource introuvable
422Validation Pydantic echouee (parametre invalide)
' +) +print('Index documentation cree:', nid) + +# ── Notes par ressource ──────────────────────────────────────────────────── +ressources = [ + ( + 'API - Projets', + '

API - Projets

' + '

GET /api/projets

' + '

Liste tous les projets.

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/projets
' + '

GET /api/projets/{nom}

' + '

Detail d un projet par son nom (valeur du label #projet).

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/projets/SlidingAutomation
' + '

POST /api/projets

' + '

Cree un nouveau projet.

' + '
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"nom":"MonProjet","objectif":"Description","stack":"Python","llm_repartition":"Claude → archi"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/projets
' + '

PATCH /api/projets/{note_id}

' + '

Modifie le statut ou l objectif. Champs optionnels : statut (actif|en-pause|archive), objectif, stack.

' + '
curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"statut":"en-pause"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/projets/NOTE_ID
' + ), + ( + 'API - Backlog', + '

API - Backlog

' + '

GET /api/backlog/{projet}

' + '

Liste les items actifs (hors fait et abandonne), tries par priorite.

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/backlog/SlidingAutomation
' + '

POST /api/backlog

' + '

Cree un item. Champs : projet (requis), titre (requis), priorite (haute|moyenne|basse, defaut: moyenne).

' + '
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"projet":"SlidingAutomation","titre":"Ma tache","priorite":"haute"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/backlog
' + '

PATCH /api/backlog/{note_id}

' + '

Modifie statut ou priorite. statut valide : a faire|en cours|bloque|fait|abandonne.

' + '
curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"statut":"fait"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/backlog/NOTE_ID
' + ), + ( + 'API - Decisions', + '

API - Decisions

' + '

GET /api/decisions/{projet}

' + '

Liste les decisions actives (statut=active).

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/decisions/SlidingAutomation
' + '

POST /api/decisions

' + '

Cree une decision. Champs : projet, enonce (requis), justification, llm.

' + '
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"projet":"SlidingAutomation","enonce":"On utilise X","justification":"Parce que Y"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/decisions
' + '

PATCH /api/decisions/{note_id}

' + '

Modifie statut (active|revisee|annulee) ou justification.

' + '
curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"statut":"annulee"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/decisions/NOTE_ID
' + ), + ( + 'API - Historique', + '

API - Historique

' + '

GET /api/historique/{projet}

' + '

Liste les entrees encore valides (encoreValide=true).

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/historique/SlidingAutomation
' + '

POST /api/historique

' + '

Cree une entree. type valide : Fait etabli|Test effectue|Hypothese invalidee|Contrainte decouverte.

' + '
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"projet":"SlidingAutomation","enonce":"Test X","type":"Test effectue","detail":"Resultat Y"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/historique
' + '

PATCH /api/historique/{note_id}

' + '

Invalider une entree obsolete : encore_valide: false.

' + '
curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"encore_valide":false}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/historique/NOTE_ID
' + ), + ( + 'API - Conversations', + '

API - Conversations

' + '

GET /api/conversations/{projet}

' + '

Liste toutes les conversations du projet.

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/conversations/SlidingAutomation
' + '

GET /api/conversations/{projet}/{note_id}

' + '

Detail avec contenu HTML d une conversation specifique.

' + '

POST /api/conversations

' + '

Cree une conversation. llm valide : Claude Sonnet|Claude Opus|Le Chat Large|Le Chat Medium.

' + '
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"projet":"SlidingAutomation","titre":"Ma session","llm":"Claude Sonnet"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/conversations
' + '

PATCH /api/conversations/{note_id}

' + '

Cloture une session : synthese_cloture (max 500 chars) et/ou statut (en-cours|clos).

' + '
curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"synthese_cloture":"Session terminee. Reste X."}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/conversations/NOTE_ID
' + ), + ( + 'API - Glossaire', + '

API - Glossaire

' + '

GET /api/glossaire/{projet}

' + '

Liste les termes avec contenu (definition).

' + '
curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/glossaire/SlidingAutomation
' + '

POST /api/glossaire

' + '

Cree un terme. Champs : projet, terme, definition (requis), synonymes_eviter.

' + '
curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \\\n'
+        '-d \'{"projet":"SlidingAutomation","terme":"Pipeline","definition":"Chaine complete de traitement"}\' \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/glossaire
' + '

PATCH /api/glossaire/{note_id}

' + '

Modifie definition et/ou synonymes_eviter.

' + ), + ( + 'API - Contexte Reprise', + '

API - Contexte Reprise

' + '

GET /api/contexte/{projet}?llm_cible=NomLLM

' + '

Genere et retourne le briefing de reprise en temps reel depuis Trilium.

' + '
curl -s -H "Authorization: CLE" \\\n'
+        '"https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large"
' + '

Retourne : {"projet":"...","tokens":N,"briefing":"# REPRISE DE CONTEXTE..."}

' + '

GET /api/contexte/{projet}/last

' + '

Retourne le dernier contexte de reprise sauvegarde dans Trilium.

' + '
curl -s -H "Authorization: CLE" \\\n'
+        'https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation/last
' + ), +] + +for titre, contenu in ressources: + res = create_note(doc_id, titre) + nid = get_note_id(res) + set_label(nid, 'type', 'documentation') + set_label(nid, 'projet', 'ContextContinuity') + update_note_content(nid, contenu) + print('Doc cree:', titre) + +# ── Skill API FastAPI pour LLMs ──────────────────────────────────────────── +skills_id = find_note_by_title('Skills', root_id) +res = create_note(skills_id, 'Skill - API FastAPI Context Continuity') +nid = get_note_id(res) +set_label(nid, 'type', 'skill') +set_label(nid, 'domaine', 'api') +set_label(nid, 'version', '1.0') + +update_note_content(nid, + '

Skill - API FastAPI Context Continuity

' + '

Ce skill permet a un LLM d appeler directement l API pour lire et ecrire dans Trilium.

' + '

Configuration

' + '' + '' + '' + '' + '' + '' + '' + '
ParametreValeur
Base URLhttps://api-trilium.bertha-cloud.fr
Auth headerAuthorization: <API_KEY>
Cle ClaudeDemander a Bastien ou lire dans .env
Cle Le ChatDemander a Bastien ou lire dans .env
Doc Swaggerhttps://api-trilium.bertha-cloud.fr/api/docs
' + '

Endpoints essentiels pour un LLM

' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
ActionAppel
Lire le contexte projetGET /api/contexte/{projet}?llm_cible=NomLLM
Voir le backlogGET /api/backlog/{projet}
Enregistrer une decisionPOST /api/decisions
Enregistrer un historiquePOST /api/historique
Creer une conversationPOST /api/conversations
Cloturer une sessionPATCH /api/conversations/{id}
Marquer backlog item faitPATCH /api/backlog/{id}
' + '

Valeurs valides par champ

' + '' + '' + '' + '' + '' + '' + '' + '' + '
ChampValeurs acceptees
prioritehaute | moyenne | basse
statut backloga faire | en cours | bloque | fait | abandonne
statut decisionactive | revisee | annulee
type historiqueFait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte
llm conversationClaude Sonnet | Claude Opus | Le Chat Large | Le Chat Medium
statut projetactif | en-pause | archive
' + '

Exemples curl complets

' + '

Lire le contexte de reprise

' + '
curl -s -H "Authorization: CLE" \\\n'
+    '"https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large" \\\n'
+    '| python3 -c "import sys,json; print(json.load(sys.stdin)[\'briefing\'])"
' + '

Enregistrer une decision

' + '
curl -s -X POST \\\n'
+    '-H "Authorization: CLE" \\\n'
+    '-H "Content-Type: application/json" \\\n'
+    '-d \'{"projet":"SlidingAutomation","enonce":"Decision prise","justification":"Raison"}\' \\\n'
+    'https://api-trilium.bertha-cloud.fr/api/decisions
' + '

Marquer un item backlog comme fait

' + '
curl -s -X PATCH \\\n'
+    '-H "Authorization: CLE" \\\n'
+    '-H "Content-Type: application/json" \\\n'
+    '-d \'{"statut":"fait"}\' \\\n'
+    'https://api-trilium.bertha-cloud.fr/api/backlog/NOTE_ID
' + '

Recuperer ce skill

' + '
curl -s -H "Authorization: CLE" \\\n'
+    '"https://api-trilium.bertha-cloud.fr/api/contexte/ContextContinuity/last"
' +) +print('Skill API FastAPI cree:', nid) +print('DONE') diff --git a/create_readme.py b/create_readme.py new file mode 100644 index 0000000..7b936a8 --- /dev/null +++ b/create_readme.py @@ -0,0 +1,153 @@ +import json, os +from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content + +with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f: + ids = json.load(f) + +res = create_note(ids['root'], 'README - Workflow Context Continuity') +nid = get_note_id(res) +set_label(nid, 'type', 'documentation') + +content = ( + '

README - Workflow Context Continuity

' + '

Systeme de gestion du contexte multi-LLM (Claude + Le Chat) via Trilium comme base pivot.
' + 'Permet de basculer entre les deux LLMs sans perdre le fil du projet.

' + + '

Prerequis

' + '
'
+    'cd ~/App/Context_continuity\n'
+    'source venv/bin/activate'
+    '
' + + '

ETAPE 1 - Debut de session : creer la conversation

' + '

Au debut de chaque session de travail avec un LLM, enregistre-la dans Trilium :

' + '
python trilium_context.py new-conversation \\\n'
+    '  --projet SlidingAutomation \\\n'
+    '  --llm "Claude Sonnet" \\\n'
+    '  --titre "Description courte de ce que tu vas faire"
' + '

Note l ID retourne — tu en auras besoin a la fin de la session.
' + 'Exemple de retour : Note l ID pour close-session : Igaa5BBIfNMw

' + + '

ETAPE 2 - Recuperer le contexte existant (optionnel)

' + '

Si tu reprends un projet en cours, genere le briefing depuis la derniere session :

' + '
python trilium_context.py generate-context \\\n'
+    '  --projet SlidingAutomation \\\n'
+    '  --llm-cible "Claude Sonnet" \\\n'
+    '  --note-id ID_DERNIERE_CONV \\\n'
+    '  --version N
' + '

Le briefing est imprime dans le terminal.
' + 'Copie-colle le texte imprime en debut de nouvelle session LLM.
' + 'Le LLM confirmera sa comprehension en 3 lignes (objectif / prochaine action / incertitude).

' + '

Pour trouver l ID de la derniere conversation :
' + 'Dans Trilium : Context Continuity > Conversations > chercher la plus recente.
' + 'Ou via terminal :

' + '
python trilium_context.py list-projects
' + + '

ETAPE 3 - Pendant la session : capitaliser au fil de l eau

' + '

Ne pas attendre la fin — enregistre les informations importantes au fur et a mesure :

' + '
# Une decision est prise\n'
+    'python trilium_context.py add-decision \\\n'
+    '  --projet SlidingAutomation \\\n'
+    '  --enonce "On fait X plutot que Y" \\\n'
+    '  --justification "Parce que Z"\n\n'
+    '# Un test a ete effectue\n'
+    'python trilium_context.py add-history \\\n'
+    '  --projet SlidingAutomation \\\n'
+    '  --type "Test effectue" \\\n'
+    '  --enonce "Test de X" \\\n'
+    '  --detail "Resultat : Y"\n\n'
+    '# Une contrainte est decouverte\n'
+    'python trilium_context.py add-history \\\n'
+    '  --projet SlidingAutomation \\\n'
+    '  --type "Contrainte decouverte" \\\n'
+    '  --enonce "X ne fonctionne pas sur Python 3.9" \\\n'
+    '  --detail "Utiliser Y a la place"
' + + '

ETAPE 4 - Limite de tokens approche : cloturer la session

' + '

Signes que la limite approche : le LLM repete des choses deja dites, oublie une contrainte, ' + 'ses reponses deviennent moins precises.
' + 'Action : demande au LLM actuel :

' + '
Fais-moi une synthese de cloture en 200 mots :\n'
+    'ou on en est, ce qui reste a faire, les blocages eventuels.
' + '

Puis enregistre cette synthese :

' + '
python trilium_context.py close-session \\\n'
+    '  --note-id ID_CONV_ACTUELLE \\\n'
+    '  --summary "COLLE ICI LA SYNTHESE DU LLM"
' + + '

ETAPE 5 - Generer le briefing de reprise

' + '
python trilium_context.py generate-context \\\n'
+    '  --projet SlidingAutomation \\\n'
+    '  --llm-cible "Le Chat Large" \\\n'
+    '  --note-id ID_CONV_ACTUELLE \\\n'
+    '  --version N
' + '

Le terminal imprime le briefing complet.
' + 'Une note "Contexte Reprise" est aussi creee automatiquement dans Trilium.

' + + '

ETAPE 6 - Basculer vers le second LLM

' + '
    ' + '
  1. Ouvre une nouvelle session sur l autre LLM (Claude ou Le Chat)
  2. ' + '
  3. Cree la nouvelle conversation dans Trilium :
    ' + '
    python trilium_context.py new-conversation \\\n'
    +    '  --projet SlidingAutomation \\\n'
    +    '  --llm "Le Chat Large" \\\n'
    +    '  --titre "Suite - description"
  4. ' + '
  5. Copie-colle le briefing imprime a l etape 5 en premier message de la nouvelle session
  6. ' + '
  7. Le LLM confirme en 3 lignes — verifie qu il a bien compris avant de continuer
  8. ' + '
  9. Reprends le travail depuis le backlog actif
  10. ' + '
' + + '

ETAPE 7 - Voir l etat du projet a tout moment

' + '
# Backlog actif\n'
+    'python trilium_context.py list-backlog --projet SlidingAutomation\n\n'
+    '# Liste des projets\n'
+    'python trilium_context.py list-projects
' + + '

Resume visuel du cycle

' + '
'
+    'DEBUT SESSION\n'
+    '     |\n'
+    '     v\n'
+    'new-conversation  (note l ID)\n'
+    '     |\n'
+    '     v\n'
+    'generate-context  (si reprise) --> coller dans LLM\n'
+    '     |\n'
+    '     v\n'
+    'TRAVAILLER\n'
+    'add-decision / add-history au fil de l eau\n'
+    '     |\n'
+    '     v  (limite approche)\n'
+    'Demander synthese au LLM\n'
+    '     |\n'
+    '     v\n'
+    'close-session  (coller la synthese)\n'
+    '     |\n'
+    '     v\n'
+    'generate-context  --> briefing imprime\n'
+    '     |\n'
+    '     v\n'
+    'new-conversation  (sur l autre LLM)\n'
+    'Coller le briefing --> LLM confirme en 3 lignes\n'
+    '     |\n'
+    '     v\n'
+    'REPRENDRE LE TRAVAIL'
+    '
' + + '

Fichiers du systeme

' + '' + '' + '' + '' + '' + '' + '' + '' + '
FichierRole
trilium_api.pyWrapper API Trilium (ne pas modifier)
trilium_init.pyInitialisation arborescence (une seule fois)
trilium_context.pyScript principal du workflow quotidien
trilium_logger.pyIntegration pipeline Sliding Automation
trilium_ids.jsonIDs des dossiers Trilium (genere par init)
.envToken ETAPI et URL Trilium (ne pas commiter)
' + + '

Skills disponibles dans Trilium

' + '

Context Continuity > Skills > Skill - API Trilium ETAPI
' + 'Contient les patterns API, erreurs connues et fixes pour TriliumNext 0.95+

' +) + +update_note_content(nid, content) +print('README cree:', nid) diff --git a/create_skill.py b/create_skill.py new file mode 100644 index 0000000..2f8da21 --- /dev/null +++ b/create_skill.py @@ -0,0 +1,85 @@ +import json, os +from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content + +with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f: + ids = json.load(f) + +# Créer le dossier Skills +skills_id = find_note_by_title('Skills', ids['root']) +if not skills_id: + res = create_note(ids['root'], 'Skills', note_type='book') + skills_id = get_note_id(res) + set_label(skills_id, 'type', 'container') + print('Dossier Skills cree:', skills_id) +else: + print('Dossier Skills existant:', skills_id) + +# Créer la note skill +res = create_note(skills_id, 'Skill - API Trilium ETAPI') +nid = get_note_id(res) +set_label(nid, 'type', 'skill') +set_label(nid, 'domaine', 'trilium') +set_label(nid, 'version', '0.95') + +content = ( + '

Skill - API Trilium ETAPI (TriliumNext 0.95+)

' + '

Contexte

' + '

TriliumNext 0.95 a restructure ses routes internes.
' + 'POST /etapi/notes retourne Router not found.
' + 'Solution validee : utiliser trilium-py 1.3.9.

' + '

Installation

' + '
pip install trilium-py python-dotenv
' + '

Authentification

' + '

Token dans .env :

' + '
TRILIUM_URL=http://localhost:4292\nTRILIUM_TOKEN=token_genere_dans_Options_ETAPI
' + '

Header : Authorization: token (sans Bearer)
' + 'Bearer accepte depuis 0.93 mais non requis.

' + '

Wrapper valide : trilium_api.py

' + '

Chemin : ~/App/Context_continuity/trilium_api.py

' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
FonctionDescription
check_api()Leve SystemExit si Trilium inaccessible
create_note(parent_id, title, content=" ", note_type="text")content=" " obligatoire — pas vide
get_note_id(result)Extrait noteId du resultat create_note
get_note(note_id)Recupere une note par ID
get_note_content(note_id)Recupere le contenu HTML
update_note_content(note_id, content)Met a jour le contenu
search_notes(query, limit=50)Recherche SANS guillemets autour du terme
search_by_label(label, value="")Syntaxe : #label=value
find_note_by_title(title, parent_id="")Retourne noteId ou None
set_label(note_id, name, value="")isInheritable=False requis en interne
get_label_value(note_id, name)Retourne valeur label ou None
' + '

Erreurs connues et fixes

' + '' + '' + '' + '' + '' + '' + '' + '
ErreurCauseFix
Router not found POST /etapi/notesTriliumNext 0.95 routing changeUtiliser trilium-py
missing argument isInheritablecreate_attribute() trilium-pyPasser isInheritable=False
Note content must be setcontent vide refusePasser content=" "
Recherche avec guillemets retourne []Parser 0.95Chercher sans guillemets
{status,code,message} sur createNote existe deja ou contenu videfind_note_by_title avant + content=" "
' + '

Patterns valides

' + '

Creer une note

' + '
result = create_note(parent_id, "Titre", note_type="text")\nnote_id = get_note_id(result)
' + '

Rechercher par label

' + '
notes = search_by_label("type", "backlogItem")\nnotes = search_by_label("projet", "SlidingAutomation")
' + '

Ajouter un attribut

' + '
set_label(note_id, "statut", "actif")
' + '

Workflow bascule LLM

' + '
'
+    'python trilium_context.py new-conversation --projet SlidingAutomation --llm "Claude Sonnet" --titre "Ma session"\n'
+    '# Travailler... puis quand limite approche :\n'
+    'python trilium_context.py close-session --note-id ID --summary "..."\n'
+    'python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Le Chat Large" --note-id ID --version 2\n'
+    '# Copier le briefing imprime dans la nouvelle session LLM'
+    '
' + '

IDs importants

' + '

Stockes dans trilium_ids.json apres python trilium_init.py
' + 'Cles : root, Projets, Conversations, Backlog, Decisions, Historique, Glossaire, ContextesReprise

' + '

Recuperer ce skill en contexte LLM

' + '
python3 -c "\nfrom trilium_api import find_note_by_title, get_note_content\nnid = find_note_by_title(\'Skill - API Trilium ETAPI\')\nprint(get_note_content(nid))\n"
' +) + +update_note_content(nid, content) +print('Skill cree et rempli:', nid) diff --git a/create_skill_structure.py b/create_skill_structure.py new file mode 100644 index 0000000..7f30367 --- /dev/null +++ b/create_skill_structure.py @@ -0,0 +1,170 @@ +import json, os +from trilium_api import create_note, get_note_id, set_label, find_note_by_title, update_note_content + +with open(os.path.expanduser('~/App/Context_continuity/trilium_ids.json')) as f: + ids = json.load(f) + +skills_id = find_note_by_title('Skills', ids['root']) +if not skills_id: + res = create_note(ids['root'], 'Skills', note_type='book') + skills_id = get_note_id(res) + set_label(skills_id, 'type', 'container') + +res = create_note(skills_id, 'Skill - Structure Context Continuity') +nid = get_note_id(res) +set_label(nid, 'type', 'skill') +set_label(nid, 'domaine', 'context-continuity') +set_label(nid, 'version', '1.0') + +content = ( + '

Skill - Structure Context Continuity dans Trilium

' + '

Ce skill decrit comment un LLM doit interagir avec la base Trilium ' + 'du systeme Context Continuity. A lire avant toute session de travail ' + 'sur un projet suivi.

' + + '

Architecture generale

' + '

Trilium est organise sous une note racine Context Continuity ' + 'avec 8 sous-dossiers :

' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
DossierContenuCle trilium_ids.json
ProjetsUne note par projet suiviProjets
ConversationsUne note par session LLMConversations
BacklogTaches a developperBacklog
DecisionsDecisions actives du projetDecisions
HistoriqueFaits etablis, tests effectues, contraintesHistorique
GlossaireVocabulaire specifique au projetGlossaire
Contextes RepriseBriefings generes pour bascule LLMContextesReprise
SkillsDocumentation technique pour LLMsSkills
' + + '

Types de notes et leurs labels obligatoires

' + + '

Note Projet

' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typeprojetoui
projetNomSansEspaces ex: SlidingAutomationoui
statutactif | en-pause | archiveoui
' + '

Contenu HTML : objectif, stack technique, repartition LLMs.

' + + '

Note Conversation

' + '' + '' + '' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typeconversationoui
projetNomSansEspacesoui
llmClaude Sonnet | Claude Opus | Le Chat Large | Le Chat Mediumoui
statuten-cours | closoui
dateYYYY-MM-DD HH:MMoui
syntheseCloturetexte libre 200 mots maxnon - rempli a la cloture
' + '

Creee via : python trilium_context.py new-conversation

' + + '

Note Backlog Item

' + '' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typebacklogItemoui
projetNomSansEspacesoui
statuta faire | en cours | bloque | fait | abandonneoui
prioritehaute | moyenne | basseoui
' + '

Creee via : python trilium_context.py add-backlog (a implementer) ' + 'ou manuellement dans Trilium.
' + 'ATTENTION : list-backlog filtre sur statut != "fait" et != "abandonne". ' + 'Utiliser exactement ces valeurs.

' + + '

Note Decision

' + '' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typedecisionoui
projetNomSansEspacesoui
statutactive | revisee | annuleeoui
llmnom du LLM ayant aidenon
' + '

Creee via : python trilium_context.py add-decision --projet X --enonce "..." --justification "..."
' + 'generate-context inclut uniquement les decisions avec statut=active.
' + 'Pour annuler une decision, changer son label statut en "annulee" dans Trilium.

' + + '

Note Historique

' + '' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typehistoriqueItemoui
projetNomSansEspacesoui
typeHistoriqueFait etabli | Test effectue | Hypothese invalidee | Contrainte decouverteoui
encoreValidetrue | falseoui
' + '

Creee via : python trilium_context.py add-history --projet X --type "..." --enonce "..." --detail "..."
' + 'generate-context inclut uniquement les entrees avec encoreValide=true.
' + 'Pour invalider une entree obsolete, changer encoreValide en "false" dans Trilium.

' + + '

Note Terme Glossaire

' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typetermeGlossaireoui
projetNomSansEspacesoui
definitiontexte court - definition operationnelleoui
' + '

ATTENTION : le label definition est lu par generate-context ' + 'pour afficher le glossaire dans le briefing. Sans ce label, la note affiche "(voir note)".
' + 'Creer manuellement dans Trilium ou via script avec set_label(nid, "definition", "...").

' + + '

Note Contexte Reprise

' + '' + '' + '' + '' + '' + '' + '' + '' + '
LabelValeurs possiblesObligatoire
typecontexteRepriseoui
projetNomSansEspacesoui
llmCiblenom du LLM destinataireoui
versionentier incremente a chaque repriseoui
tokensestimation tokens du briefingnon
conversationSourcenoteId de la conversation clôtureenon
' + '

Creee automatiquement par generate-context. Ne pas creer manuellement.

' + + '

Ce qu un LLM doit faire pendant une session

' + '

Capitaliser une decision

' + '

Des qu une decision technique ou architecturale est prise, demander a l utilisateur :

' + '
Lance cette commande pour enregistrer la decision :\n'
+    'python trilium_context.py add-decision \\\n'
+    '  --projet NomProjet \\\n'
+    '  --enonce "Enonce court et actionnable" \\\n'
+    '  --justification "Pourquoi cette decision"
' + + '

Capitaliser un test ou une contrainte

' + '

Des qu un test est effectue ou une contrainte decouverte :

' + '
python trilium_context.py add-history \\\n'
+    '  --projet NomProjet \\\n'
+    '  --type "Test effectue" \\\n'
+    '  --enonce "Ce qui a ete teste en une ligne" \\\n'
+    '  --detail "Resultat observe"
' + + '

Generer la synthese de cloture

' + '

Quand l utilisateur signale que la limite approche, produire :

' + '
Synthese de cloture :\n'
+    '- Objectif de la session : [ce qu on voulait faire]\n'
+    '- Accompli : [ce qui a ete fait]\n'
+    '- En cours : [ce qui est a moitie fait]\n'
+    '- Bloque : [blocages eventuels]\n'
+    '- Prochaine action : [premiere chose a faire a la reprise]
' + '

Format concis, 150-200 mots max, sans markdown complexe ' + '(sera colle en argument --summary).

' + + '

Erreurs frequentes a eviter

' + '' + '' + '' + '' + '' + '' + '' + '
ErreurConsequenceFix
Valeur statut avec majuscule ex: "Actif"Filtre ne trouve pas la noteToujours minuscules : "actif"
Nom projet avec espaces ex: "Sliding Automation"search_by_label echoueSans espaces : "SlidingAutomation"
Label definition absent sur termeGlossaireBriefing affiche "(voir note)"Ajouter set_label(nid, "definition", "...")
encoreValide="True" avec majusculeFiltre cherche "true" et ne trouve pasToujours "true" ou "false" minuscules
typeHistorique hors listeAffichage incoherent dans briefingUtiliser exactement : Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte
' + + '

Commande pour recuperer ce skill

' + '
python3 -c "\n'
+    'from trilium_api import find_note_by_title, get_note_content\n'
+    'nid = find_note_by_title(\'Skill - Structure Context Continuity\')\n'
+    'print(get_note_content(nid))\n'
+    '"
' +) + +update_note_content(nid, content) +print('Skill structure cree:', nid) diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..710e294 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,819 @@ +""" +mcp_server.py — Serveur MCP pur Starlette (compatible Python 3.9) +Implémente le protocole MCP (JSON-RPC 2.0) sans FastMCP. + +Transport : Streamable HTTP sur /mcp +Auth : Bearer token (clés API depuis .env) + +Usage : python mcp_server.py → écoute sur 127.0.0.1:8766 +""" +import os +import json +import secrets +import hashlib +import base64 +import time +from datetime import datetime +from dotenv import load_dotenv +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, HTMLResponse, RedirectResponse + +from trilium_api import ( + create_note, get_note_id, get_note, get_note_content, get_children, + update_note_content, search_by_label, set_label, get_label_value, + find_note_by_title, delete_note_safe, move_note_safe, add_relation_safe, +) + +load_dotenv() + +API_KEY_CLAUDE = os.getenv("API_KEY_CLAUDE", "") +API_KEY_LECHAT = os.getenv("API_KEY_LECHAT", "") +VALID_TOKENS = {API_KEY_CLAUDE: "Claude", API_KEY_LECHAT: "LeChat"} + +IDS_FILE = os.path.expanduser("~/App/Context_continuity/trilium_ids.json") +PROTOCOL_VERSION = "2025-06-18" +SERVER_INFO = {"name": "Context Continuity Trilium", "version": "1.0.0"} + +# --- Config OAuth 2.1 (Couche 1) --- +OAUTH_BASE_URL = os.getenv("OAUTH_BASE_URL", "https://mcp-trilium.bertha-cloud.fr") +SECURITY_WORD = os.getenv("SECURITY_WORD", "") +OAUTH_STATE_FILE = os.path.expanduser("~/App/Context_continuity/oauth_state.json") + +def load_oauth_state(): + with open(OAUTH_STATE_FILE) as f: + return json.load(f) + +def save_oauth_state(state): + with open(OAUTH_STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + +def load_ids(): + with open(IDS_FILE) as f: + return json.load(f) + +# --------------------------------------------------------------------------- +# Définition des tools (schema JSON pour Le Chat) +# --------------------------------------------------------------------------- + +TOOLS = [ + { + "name": "get_backlog", + "description": "Liste les items de backlog actifs d'un projet (hors fait/abandonne), tries par priorite.", + "inputSchema": { + "type": "object", + "properties": {"projet": {"type": "string", "description": "Nom du projet"}}, + "required": ["projet"], + }, + }, + { + "name": "get_decisions", + "description": "Liste les decisions actives d'un projet.", + "inputSchema": { + "type": "object", + "properties": {"projet": {"type": "string"}}, + "required": ["projet"], + }, + }, + { + "name": "get_historique", + "description": "Liste les entrees d'historique encore valides (faits, tests, contraintes).", + "inputSchema": { + "type": "object", + "properties": {"projet": {"type": "string"}}, + "required": ["projet"], + }, + }, + { + "name": "get_glossaire", + "description": "Liste les termes du glossaire d'un projet avec leur definition.", + "inputSchema": { + "type": "object", + "properties": {"projet": {"type": "string"}}, + "required": ["projet"], + }, + }, + { + "name": "get_contexte", + "description": "Genere le briefing de reprise complet d'un projet, pret a reprendre le travail.", + "inputSchema": { + "type": "object", + "properties": { + "projet": {"type": "string"}, + "llm_cible": {"type": "string", "description": "Nom du LLM cible"}, + }, + "required": ["projet"], + }, + }, + { + "name": "add_decision", + "description": "Enregistre une nouvelle decision active dans le projet.", + "inputSchema": { + "type": "object", + "properties": { + "projet": {"type": "string"}, + "enonce": {"type": "string"}, + "justification": {"type": "string"}, + }, + "required": ["projet", "enonce"], + }, + }, + { + "name": "add_history", + "description": "Enregistre une entree d'historique. type_historique : Fait etabli, Test effectue, Hypothese invalidee, Contrainte decouverte.", + "inputSchema": { + "type": "object", + "properties": { + "projet": {"type": "string"}, + "enonce": {"type": "string"}, + "type_historique": {"type": "string"}, + "detail": {"type": "string"}, + }, + "required": ["projet", "enonce", "type_historique"], + }, + }, + { + "name": "delete_note", + "description": "Supprime une note du systeme (backlog, decision, etc.) par son id. Garde-fou : refuse les notes hors systeme.", + "inputSchema": { + "type": "object", + "properties": {"note_id": {"type": "string"}}, + "required": ["note_id"], + }, + }, + { + "name": "add_backlog", + "description": "Ajoute un item au backlog. priorite : haute, moyenne ou basse.", + "inputSchema": { + "type": "object", + "properties": { + "projet": {"type": "string"}, + "titre": {"type": "string"}, + "priorite": {"type": "string"}, + }, + "required": ["projet", "titre"], + }, + }, + { + "name": "update_backlog", + "description": "Met a jour le statut d'un item backlog. statut : a faire, en cours, bloque, fait, abandonne.", + "inputSchema": { + "type": "object", + "properties": { + "note_id": {"type": "string"}, + "statut": {"type": "string"}, + }, + "required": ["note_id", "statut"], + }, + }, + { + "name": "new_conversation", + "description": "Cree une conversation. llm : Claude Sonnet, Claude Opus, Le Chat Large, Le Chat Medium.", + "inputSchema": { + "type": "object", + "properties": { + "projet": {"type": "string"}, + "titre": {"type": "string"}, + "llm": {"type": "string"}, + }, + "required": ["projet", "titre", "llm"], + }, + }, + { + "name": "close_session", + "description": "Cloture une conversation en enregistrant sa synthese.", + "inputSchema": { + "type": "object", + "properties": { + "note_id": {"type": "string"}, + "synthese": {"type": "string"}, + }, + "required": ["note_id", "synthese"], + }, + }, + { + "name": "get_note", + "description": "Lit le contenu d une note du systeme par son id (titre, type, contenu HTML). Garde-fou : refuse les notes hors types systeme.", + "inputSchema": { + "type": "object", + "properties": {"note_id": {"type": "string"}}, + "required": ["note_id"], + }, + }, + { + "name": "get_children", + "description": "Liste les notes filles d un dossier (id, titre, type). Ne retourne que les notes de type systeme.", + "inputSchema": { + "type": "object", + "properties": {"parent_id": {"type": "string"}}, + "required": ["parent_id"], + }, + }, + { + "name": "get_skills", + "description": "Liste les skills disponibles. Sans projet, retourne aussi les skills universels.", + "inputSchema": { + "type": "object", + "properties": { + "projet": {"type": "string", "description": "Optionnel — filtre par projet"}, + "portee": {"type": "string", "description": "Optionnel — universel | reference-technique | projet | ..."}, + }, + "required": [], + }, + }, + { + "name": "update_note", + "description": "Met a jour le contenu d une note existante (skill, decision, historique, doc...). format=markdown enveloppe en
 (texte redige) ; format=html ecrit le HTML tel quel (notes structurees). Garde-fou : refuse les notes hors types systeme.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "note_id": {"type": "string"},
+                "contenu": {"type": "string"},
+                "format": {"type": "string", "description": "markdown | html (defaut: markdown)"},
+            },
+            "required": ["note_id", "contenu"],
+        },
+    },
+    {
+        "name": "add_relation",
+        "description": "Cree une relation typee (object property) entre deux notes : source ~nom cible. nom doit etre dans l ontologie (impacte, revise, documentePar, contraintPar, concerneProjet, provient, illustre, reference...). Affichee dans la Note Map.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "source_id": {"type": "string"},
+                "nom": {"type": "string"},
+                "cible_id": {"type": "string"},
+            },
+            "required": ["source_id", "nom", "cible_id"],
+        },
+    },
+    {
+        "name": "move_note",
+        "description": "Deplace une note d un parent vers un autre (3 args : note_id, ancien_parent_id, nouveau_parent_id). Gere les clones.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "note_id": {"type": "string"},
+                "ancien_parent_id": {"type": "string"},
+                "nouveau_parent_id": {"type": "string"},
+            },
+            "required": ["note_id", "ancien_parent_id", "nouveau_parent_id"],
+        },
+    },
+    {
+        "name": "add_skill",
+        "description": "Enregistre un nouveau skill (savoir-faire reutilisable). portee='universel' si non lie a un projet precis.",
+        "inputSchema": {
+            "type": "object",
+            "properties": {
+                "titre": {"type": "string"},
+                "contenu": {"type": "string"},
+                "portee": {"type": "string", "description": "universel | reference-technique | projet | ... (defaut: projet)"},
+                "projet": {"type": "string", "description": "Requis sauf si portee='universel'"},
+            },
+            "required": ["titre", "contenu"],
+        },
+    },
+]
+
+# ---------------------------------------------------------------------------
+# Implémentation des tools
+# ---------------------------------------------------------------------------
+
+def tool_get_backlog(projet):
+    notes = search_by_label("type", "backlogItem")
+    actifs = [n for n in notes
+              if get_label_value(n["noteId"], "projet") == projet
+              and get_label_value(n["noteId"], "statut") not in ("fait", "abandonne")]
+    prio = {"haute": 0, "moyenne": 1, "basse": 2}
+    actifs.sort(key=lambda n: prio.get(get_label_value(n["noteId"], "priorite") or "basse", 99))
+    return [{"id": n["noteId"], "titre": n.get("title", ""),
+             "priorite": get_label_value(n["noteId"], "priorite"),
+             "statut": get_label_value(n["noteId"], "statut")} for n in actifs]
+
+def tool_get_decisions(projet):
+    notes = search_by_label("type", "decision")
+    return [{"id": n["noteId"], "enonce": n.get("title", "")} for n in notes
+            if get_label_value(n["noteId"], "projet") == projet
+            and get_label_value(n["noteId"], "statut") == "active"]
+
+def tool_get_historique(projet):
+    notes = search_by_label("type", "historiqueItem")
+    return [{"id": n["noteId"], "enonce": n.get("title", ""),
+             "type": get_label_value(n["noteId"], "typeHistorique")} for n in notes
+            if get_label_value(n["noteId"], "projet") == projet
+            and get_label_value(n["noteId"], "encoreValide") == "true"]
+
+def tool_get_glossaire(projet):
+    notes = search_by_label("type", "termeGlossaire")
+    return [{"id": n["noteId"], "terme": n.get("title", ""),
+             "definition": get_label_value(n["noteId"], "definition") or ""} for n in notes
+            if get_label_value(n["noteId"], "projet") == projet]
+
+def tool_get_contexte(projet, llm_cible="LLM"):
+    decisions = [n for n in search_by_label("type", "decision")
+                 if get_label_value(n["noteId"], "projet") == projet
+                 and get_label_value(n["noteId"], "statut") == "active"]
+    historique = [n for n in search_by_label("type", "historiqueItem")
+                  if get_label_value(n["noteId"], "projet") == projet
+                  and get_label_value(n["noteId"], "encoreValide") == "true"]
+    backlog = [n for n in search_by_label("type", "backlogItem")
+               if get_label_value(n["noteId"], "projet") == projet
+               and get_label_value(n["noteId"], "statut") not in ("fait", "abandonne")]
+    glossaire = [n for n in search_by_label("type", "termeGlossaire")
+                 if get_label_value(n["noteId"], "projet") == projet]
+    date = datetime.now().strftime("%d/%m/%Y")
+    lines = [f"# REPRISE DE CONTEXTE — {projet} — {date}",
+             f"**LLM cible : {llm_cible}**", ""]
+    if decisions:
+        lines += ["## Décisions actives (ne pas remettre en question)"]
+        lines += [f"- {d.get('title','?')}" for d in decisions] + [""]
+    if historique:
+        lines += ["## Historique — déjà testé / établi"]
+        for h in historique:
+            t = get_label_value(h["noteId"], "typeHistorique") or ""
+            lines.append(f"- [{t}] {h.get('title','?')}")
+        lines.append("")
+    if glossaire:
+        lines += ["## Glossaire"]
+        for g in glossaire:
+            d = get_label_value(g["noteId"], "definition") or "(voir note)"
+            lines.append(f"- **{g.get('title','?')}** : {d}")
+        lines.append("")
+    if backlog:
+        lines += ["## Backlog actif"]
+        prio = {"haute": 0, "moyenne": 1, "basse": 2}
+        backlog.sort(key=lambda b: prio.get(get_label_value(b["noteId"], "priorite") or "basse", 99))
+        for b in backlog:
+            p = get_label_value(b["noteId"], "priorite") or "?"
+            s = get_label_value(b["noteId"], "statut") or "?"
+            lines.append(f"- [{s}] [{p}] {b.get('title','?')}")
+        lines.append("")
+    lines += ["---", "Confirme en 3 lignes : (a) objectif (b) prochaine action (c) incertitude"]
+    return "\n".join(lines)
+
+def tool_add_decision(projet, enonce, justification=""):
+    ids = load_ids()
+    res = create_note(ids["Decisions"], enonce,
+                      content=f"

Justification : {justification or '-'}

") + nid = get_note_id(res) + set_label(nid, "type", "decision"); set_label(nid, "projet", projet) + set_label(nid, "statut", "active") + return {"id": nid, "enonce": enonce, "status": "cree"} + +def tool_add_history(projet, enonce, type_historique, detail=""): + valides = ["Fait etabli", "Test effectue", "Hypothese invalidee", "Contrainte decouverte"] + if type_historique not in valides: + return {"error": f"type_historique invalide. Valeurs : {valides}"} + ids = load_ids() + res = create_note(ids["Historique"], enonce, + content=f"

Type : {type_historique}

Détail : {detail or '-'}

") + nid = get_note_id(res) + set_label(nid, "type", "historiqueItem"); set_label(nid, "projet", projet) + set_label(nid, "typeHistorique", type_historique); set_label(nid, "encoreValide", "true") + return {"id": nid, "enonce": enonce, "status": "cree"} + +def tool_add_backlog(projet, titre, priorite="moyenne"): + if priorite not in ("haute", "moyenne", "basse"): + return {"error": "priorite doit etre haute, moyenne ou basse"} + ids = load_ids() + res = create_note(ids["Backlog"], titre) + nid = get_note_id(res) + set_label(nid, "type", "backlogItem"); set_label(nid, "projet", projet) + set_label(nid, "priorite", priorite); set_label(nid, "statut", "a faire") + return {"id": nid, "titre": titre, "status": "cree"} + +def tool_update_backlog(note_id, statut): + valides = ["a faire", "en cours", "bloque", "fait", "abandonne"] + if statut not in valides: + return {"error": f"statut invalide. Valeurs : {valides}"} + try: + get_note(note_id) + except Exception: + return {"error": "Backlog item introuvable"} + set_label(note_id, "statut", statut) + return {"id": note_id, "statut": statut, "status": "mis a jour"} + +def tool_new_conversation(projet, titre, llm): + ids = load_ids() + date = datetime.now().strftime("%Y-%m-%d %H:%M") + full = f"[{llm}] {date} — {titre}" + content = (f"

{full}

" + f"" + f"" + f"" + f"
Projet{projet}
LLM{llm}
Date{date}
Synthèse de clôtureÀ remplir en fin de session
") + res = create_note(ids["Conversations"], full, content=content) + nid = get_note_id(res) + set_label(nid, "type", "conversation"); set_label(nid, "projet", projet) + set_label(nid, "llm", llm); set_label(nid, "statut", "en-cours"); set_label(nid, "date", date) + return {"id": nid, "titre": full, "status": "cree"} + +def tool_close_session(note_id, synthese): + try: + content = get_note_content(note_id) + except Exception: + return {"error": "Conversation introuvable"} + safe = synthese.replace("<", "<").replace(">", ">") + if "À remplir en fin de session" in content: + content = content.replace("À remplir en fin de session", safe) + else: + content += f"

Synthèse

{safe}

" + update_note_content(note_id, content) + set_label(note_id, "syntheseCloture", synthese[:200]); set_label(note_id, "statut", "clos") + return {"id": note_id, "status": "cloture"} + +def tool_get_skills(projet=None, portee=None): + notes = search_by_label("type", "skill") + result = [] + for n in notes: + nid = n["noteId"] + note_projet = get_label_value(nid, "projet") + note_portee = get_label_value(nid, "portee") + if projet and note_projet != projet and note_portee != "universel": + continue + if portee and note_portee != portee: + continue + result.append({ + "id": nid, "titre": n.get("title", ""), + "portee": note_portee or "", "projet": note_projet or "", + "contenu": get_note_content(nid), + }) + return result + +def _type_systeme(note): + for a in note.get("attributes", []): + if a.get("type") == "label" and a.get("name") == "type": + return a.get("value") + return None + +def tool_get_note(note_id): + from trilium_api import TYPES_SYSTEME, get_note_content + note = get_note(note_id) + if not note: + return {"error": "Note introuvable"} + tv = _type_systeme(note) + if tv not in TYPES_SYSTEME: + return {"error": "Refus : note hors type systeme (type=%s)" % tv} + return {"id": note_id, "titre": note.get("title", ""), "type": tv, + "contenu": get_note_content(note_id)} + +def tool_get_children(parent_id): + from trilium_api import TYPES_SYSTEME + out = [] + for child in get_children(parent_id): + cid = child.get("noteId") + n = get_note(cid) + if not n: + continue + tv = _type_systeme(n) + if tv in TYPES_SYSTEME: + out.append({"id": cid, "titre": n.get("title", ""), "type": tv}) + return {"parent": parent_id, "count": len(out), "notes": out} + +def tool_update_note(note_id, contenu, format="markdown"): + from trilium_api import TYPES_SYSTEME + note = get_note(note_id) + if not note: + return {"error": "Note introuvable"} + type_val = None + for a in note.get("attributes", []): + if a.get("type") == "label" and a.get("name") == "type": + type_val = a.get("value") + break + if type_val not in TYPES_SYSTEME: + return {"error": "Refus : note sans type systeme connu (type=%s)" % type_val} + if format == "html": + body = contenu + else: + safe = contenu.replace("<", "<").replace(">", ">") + body = "
" + safe + "
" + update_note_content(note_id, body) + return {"id": note_id, "titre": note.get("title", ""), "format": format, "status": "mis a jour"} + +def tool_add_relation(source_id, nom, cible_id): + ok, message = add_relation_safe(source_id, nom, cible_id) + return {"success": ok, "message": message} + +def tool_move_note(note_id, ancien_parent_id, nouveau_parent_id): + ok, message = move_note_safe(note_id, ancien_parent_id, nouveau_parent_id) + return {"success": ok, "message": message} + +def tool_add_skill(titre, contenu, portee="projet", projet=None): + if portee != "universel" and not projet: + return {"error": "projet requis sauf si portee='universel'"} + ids = load_ids() + existant = find_note_by_title(titre, ids["Skills"]) + if existant: + return {"error": f"Un skill '{titre}' existe déjà", "id": existant} + safe = contenu.replace("<", "<").replace(">", ">") + res = create_note(ids["Skills"], titre, content=f"
{safe}
") + nid = get_note_id(res) + set_label(nid, "type", "skill") + set_label(nid, "portee", portee) + if projet: + set_label(nid, "projet", projet) + return {"id": nid, "titre": titre, "status": "cree"} + +def tool_delete_note(note_id): + ok, message = delete_note_safe(note_id) + return {"success": ok, "message": message} + +DISPATCH = { + "get_backlog": tool_get_backlog, "get_decisions": tool_get_decisions, + "get_historique": tool_get_historique, "get_glossaire": tool_get_glossaire, + "get_contexte": tool_get_contexte, "add_decision": tool_add_decision, + "add_history": tool_add_history, "add_backlog": tool_add_backlog, + "delete_note": tool_delete_note, + "update_backlog": tool_update_backlog, "new_conversation": tool_new_conversation, + "close_session": tool_close_session, "get_skills": tool_get_skills, + "add_skill": tool_add_skill, + "move_note": tool_move_note, + "add_relation": tool_add_relation, + "update_note": tool_update_note, + "get_note": tool_get_note, "get_children": tool_get_children, +} + +# --------------------------------------------------------------------------- +# Handler JSON-RPC MCP +# --------------------------------------------------------------------------- + +def check_auth(request: Request): + auth = request.headers.get("authorization", "") + token = auth.replace("Bearer ", "").strip() + # 1) Tokens statiques (Le Chat, cle Claude historique) + if token in VALID_TOKENS: + return VALID_TOKENS[token] + # 2) Tokens OAuth emis (Claude Desktop) + if token.startswith("at_"): + oauth = load_oauth_state() + meta = oauth["tokens"].get(token) + if meta: + return "Claude-OAuth" + return None + +async def mcp_endpoint(request: Request): + # Auth + llm = check_auth(request) + if not llm: + return JSONResponse( + {"error": "invalid_token", "error_description": "Authentication required"}, + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + ) + + body = await request.json() + method = body.get("method") + req_id = body.get("id") + params = body.get("params", {}) + + # initialize + if method == "initialize": + return JSONResponse({ + "jsonrpc": "2.0", "id": req_id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + }, + }) + + # notifications/initialized (pas de réponse attendue) + if method == "notifications/initialized": + return Response(status_code=202) + + # tools/list + if method == "tools/list": + return JSONResponse({ + "jsonrpc": "2.0", "id": req_id, + "result": {"tools": TOOLS}, + }) + + # tools/call + if method == "tools/call": + tool_name = params.get("name") + args = params.get("arguments", {}) + fn = DISPATCH.get(tool_name) + if not fn: + return JSONResponse({ + "jsonrpc": "2.0", "id": req_id, + "error": {"code": -32601, "message": f"Tool inconnu : {tool_name}"}, + }) + try: + result = fn(**args) + return JSONResponse({ + "jsonrpc": "2.0", "id": req_id, + "result": { + "content": [{"type": "text", + "text": json.dumps(result, ensure_ascii=False, indent=2)}], + }, + }) + except Exception as e: + return JSONResponse({ + "jsonrpc": "2.0", "id": req_id, + "error": {"code": -32603, "message": str(e)}, + }) + + # méthode inconnue + return JSONResponse({ + "jsonrpc": "2.0", "id": req_id, + "error": {"code": -32601, "message": f"Méthode inconnue : {method}"}, + }) + +async def health(request: Request): + return JSONResponse({"status": "ok", "server": "mcp", "version": "1.0.0"}) + +async def well_known_protected_resource(request: Request): + return JSONResponse({ + "resource": OAUTH_BASE_URL + "/mcp", + "authorization_servers": [OAUTH_BASE_URL], + }) + +async def well_known_authorization_server(request: Request): + return JSONResponse({ + "issuer": OAUTH_BASE_URL, + "authorization_endpoint": OAUTH_BASE_URL + "/authorize", + "token_endpoint": OAUTH_BASE_URL + "/token", + "registration_endpoint": OAUTH_BASE_URL + "/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + }) + +async def register(request: Request): + """Dynamic Client Registration (RFC 7591).""" + try: + body = await request.json() + except Exception: + body = {} + client_id = "client_" + secrets.token_urlsafe(16) + state = load_oauth_state() + state["clients"][client_id] = { + "client_id": client_id, + "redirect_uris": body.get("redirect_uris", []), + "client_name": body.get("client_name", "unknown"), + "created_at": int(time.time()), + } + save_oauth_state(state) + # Reponse RFC 7591 : client public (pas de secret), auth method none + return JSONResponse({ + "client_id": client_id, + "redirect_uris": body.get("redirect_uris", []), + "client_name": body.get("client_name", "unknown"), + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + }, status_code=201) + +AUTHORIZE_PAGE = """ +Autorisation Trilium MCP + +

Autorisation Trilium MCP

+

Saisissez le mot de securite pour autoriser l acces.

+{error} +
+ + + + + + + +
""" + +async def authorize_get(request: Request): + q = request.query_params + page = AUTHORIZE_PAGE.format( + error="", + client_id=q.get("client_id", ""), + redirect_uri=q.get("redirect_uri", ""), + state=q.get("state", ""), + code_challenge=q.get("code_challenge", ""), + code_challenge_method=q.get("code_challenge_method", "S256"), + ) + return HTMLResponse(page) + +async def authorize_post(request: Request): + form = await request.form() + word = form.get("security_word", "") + client_id = form.get("client_id", "") + redirect_uri = form.get("redirect_uri", "") + state_param = form.get("state", "") + code_challenge = form.get("code_challenge", "") + code_challenge_method = form.get("code_challenge_method", "S256") + + if word != SECURITY_WORD: + page = AUTHORIZE_PAGE.format( + error='

Mot de securite incorrect.

', + client_id=client_id, redirect_uri=redirect_uri, + state=state_param, code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + return HTMLResponse(page, status_code=401) + + oauth = load_oauth_state() + if client_id not in oauth["clients"]: + return JSONResponse({"error": "client inconnu"}, status_code=400) + + code = "code_" + secrets.token_urlsafe(24) + oauth["auth_codes"][code] = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "created_at": int(time.time()), + "used": False, + } + save_oauth_state(oauth) + + sep = "&" if "?" in redirect_uri else "?" + location = redirect_uri + sep + "code=" + code + if state_param: + location += "&state=" + state_param + return RedirectResponse(location, status_code=302) + +def verify_pkce(code_verifier, code_challenge): + """Verifie PKCE S256 : base64url(sha256(verifier)) == challenge.""" + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + computed = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + return computed == code_challenge + +async def token(request: Request): + form = await request.form() + grant_type = form.get("grant_type", "") + oauth = load_oauth_state() + + if grant_type == "authorization_code": + code = form.get("code", "") + code_verifier = form.get("code_verifier", "") + entry = oauth["auth_codes"].get(code) + if not entry: + return JSONResponse({"error": "invalid_grant"}, status_code=400) + if entry.get("used"): + return JSONResponse({"error": "invalid_grant", "error_description": "code deja utilise"}, status_code=400) + # Validation PKCE + challenge = entry.get("code_challenge", "") + if challenge: + if not code_verifier or not verify_pkce(code_verifier, challenge): + return JSONResponse({"error": "invalid_grant", "error_description": "PKCE echec"}, status_code=400) + # Emettre les tokens + access_token = "at_" + secrets.token_urlsafe(32) + refresh_token = "rt_" + secrets.token_urlsafe(32) + oauth["tokens"][access_token] = { + "client_id": entry["client_id"], + "refresh_token": refresh_token, + "created_at": int(time.time()), + "type": "access", + } + oauth["auth_codes"][code]["used"] = True + save_oauth_state(oauth) + return JSONResponse({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 31536000, + "refresh_token": refresh_token, + }) + + elif grant_type == "refresh_token": + rt = form.get("refresh_token", "") + # Retrouver le token associe a ce refresh + for at, meta in list(oauth["tokens"].items()): + if meta.get("refresh_token") == rt: + new_at = "at_" + secrets.token_urlsafe(32) + oauth["tokens"][new_at] = { + "client_id": meta["client_id"], + "refresh_token": rt, + "created_at": int(time.time()), + "type": "access", + } + save_oauth_state(oauth) + return JSONResponse({ + "access_token": new_at, + "token_type": "Bearer", + "expires_in": 31536000, + "refresh_token": rt, + }) + return JSONResponse({"error": "invalid_grant"}, status_code=400) + + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + +app = Starlette(routes=[ + Route("/mcp", mcp_endpoint, methods=["POST"]), + Route("/", mcp_endpoint, methods=["POST"]), + Route("/health", health, methods=["GET"]), + Route("/.well-known/oauth-protected-resource", well_known_protected_resource, methods=["GET"]), + Route("/.well-known/oauth-authorization-server", well_known_authorization_server, methods=["GET"]), + Route("/register", register, methods=["POST"]), + Route("/authorize", authorize_get, methods=["GET"]), + Route("/authorize", authorize_post, methods=["POST"]), + Route("/token", token, methods=["POST"]), +]) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8766) diff --git a/skill_sliding_design_system.md b/skill_sliding_design_system.md new file mode 100644 index 0000000..662007c --- /dev/null +++ b/skill_sliding_design_system.md @@ -0,0 +1,469 @@ +# SKILL — Sliding Design System (Pernod Ricard) + +Ce skill décrit le **design system « PR Editorial »** et le format de données +que consomme le moteur de rendu `render_engine_v2.py` pour produire des +présentations PowerPoint au format Pernod Ricard. + +**Périmètre de ce skill : design et production uniquement.** Il ne traite pas +de la structuration narrative, de la rédaction ou de la rhétorique. Il répond +à une seule question : *comment produire un fichier YAML valide qui, passé au +moteur, génère un PPTX conforme à la charte Pernod Ricard.* + +--- + +## 1. Principe de fonctionnement + +Le moteur prend en entrée un fichier **YAML** décrivant une liste de slides. +Chaque slide déclare un `layout` (parmi 21) et les champs de contenu de ce +layout. Le moteur applique automatiquement la charte (couleurs, polices, +grille, centrage vertical). Tu n'as jamais à spécifier de couleur, de police +ou de position en mode standard : tu fournis le contenu, le moteur compose. + +Commande de rendu : +```bash +python3 render_engine_v2.py presentation.yaml sortie.pptx \ + --theme theme_v2.yaml --components components_v2.yaml --layouts layouts_v2.yaml +``` + +Structure générale du YAML : +```yaml +slides: + - layout: cover_split + titre: "..." + - layout: kpi_grid + titre: "..." + items: [...] +``` + +--- + +## 2. La charte (appliquée automatiquement) + +Tu n'écris jamais ces valeurs en mode standard ; elles sont là pour information. + +**Couleurs** +| Token | Hex | Usage | +|---|---|---| +| navy | `#061033` | dominant — fonds sombres, titres | +| navy_light | `#10204D` | cercles décoratifs sur fond sombre | +| coral | `#F4795B` | accent UNIQUE — chiffres, badges, lignes | +| glacier | `#8FA9D0` | sous-titres sur fond sombre, 3e couleur | +| slate | `#46505A` | 2e couleur de cycle | +| card | `#F4F1EC` | fond des cartes (gris chaud) | +| card_alt | `#EAF0F8` | variante froide | +| body | `#2B3440` | texte courant sur fond clair | +| muted | `#8A93A0` | légendes, sources | + +**Polices** : Cambria (titres, chiffres) / Calibri (corps). +**Format slide** : 33,87 × 19,05 cm (16:9). +**Règle d'accent** : le corail est rare. Un seul élément corail dominant par slide ; le navy domine. +**Rythme sandwich** : ouverture, transitions et clôture sur fond sombre (navy) ; contenu sur fond clair. + +--- + +## 3. Les 21 layouts standards + +Chaque layout liste ses champs **requis** (obligatoires) et **optionnels**. +`titre` doit être une formulation affirmative (le message du slide), pas un label. + +### Ouverture / Clôture / Structure + +**cover_split** — couverture (fond sombre) +Requis : `titre` · Optionnel : `sous_titre` +```yaml +- layout: cover_split + titre: "Titre de la présentation" + sous_titre: "Tagline en une phrase" +``` + +**section_divider** — transition de section (fond sombre, numéro auto) +Requis : `titre` +```yaml +- layout: section_divider + titre: "Nom de la section" +``` + +**end_slide** — clôture (fond sombre) +Requis : `message` +```yaml +- layout: end_slide + message: "Phrase de conclusion" +``` + +### Synthèse / Message + +**executive_summary** — synthèse situation/complication/résolution +Requis : `titre, situation, complication, resolution` +```yaml +- layout: executive_summary + titre: "Le So What en une phrase affirmative" + situation: "État des lieux factuel" + complication: "Le problème ou la tension" + resolution: "La réponse proposée" +``` + +**key_message** — citation/message fort (fond sombre) +Requis : `message` · Optionnel : `detail` +```yaml +- layout: key_message + message: "Le message clé en une phrase forte." + detail: "Sous-texte optionnel." +``` + +### Données + +**big_stat** — un chiffre héro plein écran +Requis : `titre, valeur` · Optionnel : `description, source` +```yaml +- layout: big_stat + titre: "Titre affirmatif" + valeur: "78%" + description: "ce que le chiffre signifie" + source: "Référence — 2026" +``` + +**kpi_grid** — 2 à 6 cartes KPI +Requis : `titre, items` +```yaml +- layout: kpi_grid + titre: "Titre affirmatif" + items: + - label: "Indicateur 1" + valeur: "+13%" + description: "Source ou contexte" + - label: "Indicateur 2" + valeur: "85%" + description: "Source ou contexte" +``` +Note : `valeur` est une chaîne. Les valeurs courtes (≤6 car.) s'affichent en +très grand, les valeurs longues sont réduites automatiquement. + +### Comparaison + +**two_cols_text** — deux colonnes en cartes (gauche navy, droite corail) +Requis : `titre, left, right` +```yaml +- layout: two_cols_text + titre: "Titre affirmatif" + left: + titre: "Titre colonne gauche" + bullets: + - texte: "Premier point" + - texte: "Deuxième point" + right: + titre: "Titre colonne droite" + bullets: + - texte: "Premier point" +``` + +**comparison_table** — tableau comparatif multi-critères +Requis : `titre, headers, rows` (max ~5 colonnes, 8 lignes) +```yaml +- layout: comparison_table + titre: "Titre affirmatif" + headers: ["Critère", "Option A", "Option B"] + rows: + - label: "Premier critère" + values: ["Valeur A", "Valeur B"] + - label: "Deuxième critère" + values: ["Valeur A", "Valeur B"] +``` + +**from_to_pairs** — transformation avant/après +Requis : `titre, pairs` · Optionnel : `label_from, label_to` (max 5 paires) +```yaml +- layout: from_to_pairs + titre: "Titre affirmatif" + label_from: "SITUATION ACTUELLE" + label_to: "SITUATION CIBLE" + pairs: + - from: "État de départ" + to: "État cible" + - from: "Autre point" + to: "Autre cible" +``` + +### Concept / Texte + +**circular_diagram** — 3 à 6 cercles + légende +Requis : `titre, segments` +```yaml +- layout: circular_diagram + titre: "Titre affirmatif" + segments: + - label: "Concept 1" + description: "Description courte" + - label: "Concept 2" + description: "Description courte" + - label: "Concept 3" + description: "Description courte" +``` + +**default_bullets** — liste de points (max 5) +Requis : `titre, bullets` +Le format « Mot-clé : explication » met le mot-clé en gras navy. +```yaml +- layout: default_bullets + titre: "Titre affirmatif" + bullets: + - texte: "Mot-clé : explication du point" + niveau: 1 + - texte: "Autre mot-clé : explication" + niveau: 1 +``` + +### Process / Planning + +**numbered_steps** — étapes en cartes avec badge numéroté (2 à 5) +Requis : `titre, steps` +```yaml +- layout: numbered_steps + titre: "Titre affirmatif" + steps: + - numero: 1 + titre: "Première étape" + description: "Ce que ça implique" + - numero: 2 + titre: "Deuxième étape" + description: "Ce que ça implique" +``` + +**process_arrow** — flux horizontal de 3 à 6 étapes (sans dates) +Requis : `titre, steps` +```yaml +- layout: process_arrow + titre: "Titre affirmatif" + steps: + - titre: "Étape 1" + description: "Ce qui se passe" + - titre: "Étape 2" + description: "Ce qui se passe" + - titre: "Étape 3" + description: "Ce qui se passe" +``` + +**phases_timeline** — phases avec périodes +Requis : `titre, phases` +```yaml +- layout: phases_timeline + titre: "Titre affirmatif" + phases: + - label: "Mois 1-2" + periode: "Ce qui se passe" + - label: "Mois 3-8" + periode: "Ce qui se passe" +``` + +**gantt_timeline** — Gantt par workstreams +Requis : `titre, periods, workstreams` (max 3 workstreams, 4 tâches chacun) +`start`/`end` = index dans `periods` (0-based). +```yaml +- layout: gantt_timeline + titre: "Titre affirmatif" + periods: ["Juin", "Juil", "Août", "Sept"] + workstreams: + - label: "Workstream 1" + tasks: + - label: "Tâche A" + start: 0 + end: 2 + - label: "Workstream 2" + tasks: + - label: "Tâche B" + start: 1 + end: 3 +``` + +**yearly_timeline** — frise chronologique +Requis : `titre, milestones` (max 6, `actif: true` met le jalon en corail) +```yaml +- layout: yearly_timeline + titre: "Titre affirmatif" + milestones: + - annee: "2024" + label: "Premier jalon" + - annee: "2025" + label: "Deuxième jalon" + - annee: "2026" + label: "Jalon courant" + actif: true +``` + +### Gouvernance / Organisation / Décision + +**raci_table** — matrice RACI +Requis : `titre, roles, tasks` (max 4 rôles, 8 tâches) +Lettres : R (Responsable, corail), A (Autorité, navy), C (Consulté, slate), I (Informé, muted). +```yaml +- layout: raci_table + titre: "Titre affirmatif" + roles: ["Rôle 1", "Rôle 2", "Rôle 3"] + tasks: + - label: "Première activité" + raci: ["A", "R", "C"] + - label: "Deuxième activité" + raci: ["A", "I", "R"] +``` + +**org_chart** — organigramme (max 3 niveaux) +Requis : `titre, root` +Les `children` peuvent être des objets `{label, children}` OU de simples +chaînes. Max 4 enfants directs, 3 petits-enfants par enfant (au-delà, ça serre). +```yaml +- layout: org_chart + titre: "Titre affirmatif" + root: + label: "Responsable racine" + children: + - label: "Manager 1" + children: + - "Équipe A" + - "Équipe B" + - label: "Manager 2" + children: + - "Équipe C" +``` + +**matrix_2x2** — matrice effort/impact +Requis : `titre, items` · Optionnel : `axis_x, axis_y, quadrants` (max 8 items) +`x` et `y` vont de 0 à 100. +```yaml +- layout: matrix_2x2 + titre: "Titre affirmatif" + axis_x: + label: "Effort" + low: "Faible" + high: "Élevé" + axis_y: + label: "Impact" + low: "Faible" + high: "Élevé" + quadrants: + top_left: "Gains rapides" + top_right: "Projets stratégiques" + bottom_left: "Déprioritiser" + bottom_right: "À planifier" + items: + - label: "Initiative A" + x: 30 + y: 80 + - label: "Initiative B" + x: 70 + y: 75 +``` + +**recommendation_card** — carte de recommandation (sidebar navy) +Requis : `titre, headline, bullets` · Optionnel : `numero, cta` +```yaml +- layout: recommendation_card + numero: 1 + titre: "Nom court de la recommandation" + headline: "TROIS DÉCISIONS À PRENDRE MAINTENANT" + cta: "Décider en réunion du 30 juin" + bullets: + - texte: "Première décision précise" + - texte: "Deuxième décision précise" +``` + +--- + +## 4. Le flux libre (freeform) — composition sur mesure + +Pour les slides qui ne rentrent dans aucun layout standard, utilise +`layout: freeform`. La slide est composée de **blocs positionnés sur une +grille 12 × 12** (colonnes 0-12, lignes 0-12). La charte reste **imposée** : +les couleurs ne peuvent être que des tokens nommés (jamais de hex). + +Règles de la grille : +- `col` (0-12) = colonne de départ, `row` (0-12) = ligne de départ +- `w` = largeur en colonnes, `h` = hauteur en lignes +- Contraintes : `col + w ≤ 12` et `row + h ≤ 12` +- Maximum 8 blocs par slide +- `mode: light` (fond blanc) ou `mode: dark` (fond navy) +- `footer: false` pour masquer le pied de page (affiché par défaut, même sur fond sombre) + +Note : `freeform` est géré directement par le moteur de rendu et ne figure pas +dans `layouts_v2.yaml`. Un validateur basé sur ce fichier peut donc le signaler +comme « layout inconnu » — c'est sans effet, le rendu fonctionne normalement. + +Types de blocs disponibles : +| type | description | champs spécifiques | +|---|---|---| +| `title` / `heading` | titre serif | `size` (déf. 28), `align`, `color` | +| `text` | texte courant (Calibri) | `size`, `bold`, `italic`, `serif`, `align`, `color` | +| `stat` | grand chiffre serif corail | `size` (déf. 72), `align`, `color` | +| `circle` | cercle plein (motif charte) | `color` (diamètre = min(w,h)) | +| `badge` | cercle numéroté, texte blanc centré | `text`, `color` | +| `card` | carte arrondie avec ombre | `color` (déf. card) | +| `rect` | rectangle plein | `color`, `rounded` | +| `line` | ligne / séparateur | `color`, `weight` (h:0 = horizontale) | + +Tokens couleur autorisés : `navy, navy_light, coral, glacier, slate, card, +white, body, muted`. Aucun hex. + +Exemple — slide manifeste (fond sombre, grand chiffre + titre + accent) : +```yaml +- layout: freeform + mode: dark + blocks: + - type: stat + text: "3" + col: 0.5 + row: 1 + w: 3 + h: 4 + size: 150 + color: coral + align: center + - type: title + text: "trois convictions structurantes" + col: 4 + row: 1.5 + w: 7.5 + h: 3 + size: 30 + color: white + - type: line + col: 4 + row: 4.5 + w: 7 + h: 0 + color: coral + weight: 2 + - type: text + text: "Le sous-texte en glacier, italique." + col: 4 + row: 5 + w: 7.5 + h: 2 + size: 18 + italic: true + color: glacier +``` + +--- + +## 5. Règles de production (à respecter pour un YAML valide) + +1. **Layouts** : utiliser uniquement les 21 noms listés (+ `freeform`). +2. **Champs requis** : chaque layout doit avoir tous ses champs requis, non vides. +3. **Chaînes** : toujours entre guillemets. Échapper les guillemets internes. +4. **Chiffres de contenu** (`valeur`, `stat`) : toujours des chaînes (`"78%"`). +5. **Entiers de structure** (`numero`, `niveau`, `start`, `end`, `x`, `y`) : sans guillemets. +6. **`titre`** : formulation affirmative (le message), jamais un label thématique. +7. **Pas de syntaxe Markdown** dans les valeurs : pas de `**gras**`, pas de `#`. + Le formatage est géré par le moteur, pas par le texte. +8. **Champ optionnel absent** : l'omettre (ne pas mettre `null`). +9. **Freeform** : respecter `col+w ≤ 12`, `row+h ≤ 12`, max 8 blocs, tokens charte uniquement. +10. **Densité** : respecter les maxima par layout (ex. kpi_grid ≤ 6, bullets ≤ 5, + org_chart ≤ 4 branches). Au-delà, le rendu se dégrade. + +--- + +## 6. Sortie attendue + +Quand on te demande de produire une présentation au format Pernod Ricard, +tu produis **uniquement le YAML** décrit ci-dessus — rien d'autre. Ce YAML +est destiné à être passé tel quel à `render_engine_v2.py`. Pas de texte +d'introduction, pas de commentaire, pas de Markdown autour : le YAML brut, +directement exploitable. diff --git a/skill_synology_code.md b/skill_synology_code.md new file mode 100644 index 0000000..eac54a2 --- /dev/null +++ b/skill_synology_code.md @@ -0,0 +1,238 @@ +# Skill — Modification de code sur GrosseBertha (Synology DS218) + +## Contexte + +GrosseBertha est un Synology DS218 (ARM64, DSM 7.3.2). +L'utilisateur est connecté en SSH avec l'utilisateur `Master`. +Le shell est `/bin/sh` (pas bash). + +--- + +## Règles de procédure — OBLIGATOIRES + +Ces règles s'appliquent à toutes les sessions de travail sur le pipeline Sliding. +Elles existent pour limiter la consommation de tokens et les itérations inutiles. + +### 1. Demander accord avant tout code lourd +Ne jamais générer un script de patch, une fonction ou un fichier sans avoir +demandé et obtenu l'accord explicite de l'utilisateur. +"Je génère ?" → attendre "oui" avant de coder. + +### 2. Lire les lignes exactes avant de patcher +Avant tout patch sur render_engine.py, demander le `sed` sur les lignes concernées : +```bash +sed -n 'X,Yp' ~/App/Sliding/python-pptx/render_engine.py +``` +Ne jamais supposer que le fichier sur GrosseBertha correspond au fichier projet. +Le fichier sur GrosseBertha diverge souvent des fichiers de référence. + +### 3. Feedback visuel — PNG uniquement +Demander un PNG de la slide concernée uniquement, pas le PDF complet. +Depuis PowerPoint : clic droit sur la slide → Enregistrer en image. +Évite la rasterisation de PDF multi-pages inutile. + +### 4. Résultat terminal minimal +L'utilisateur ne colle que le résultat essentiel : +- Si OK : juste "OK" ou les lignes de confirmation +- Si erreur : uniquement le traceback (pas tout le terminal) + +### 5. Nettoyage unicode avant patch +Le fichier render_engine.py contient des tirets unicode `─` dans les commentaires. +Toujours nettoyer en début de script de patch : +```python +import re +c = re.sub(r'[─]+', '-', c) +``` +Sans ce nettoyage, les patterns de remplacement échouent. + +### 6. Valider le fichier final, pas la variable intermédiaire +```python +# CORRECT +with open(SRC) as f: + final = f.read() +ast.parse(final) + +# INCORRECT +ast.parse(content) # content peut etre une variable intermediaire corrompue +``` + +--- + +## Contrainte absolue — 1 seule ligne terminal + +**Toutes les commandes terminal doivent être sur UNE SEULE LIGNE.** + +```bash +# CORRECT +python3 -c "import os; print(os.getcwd())" + +# INCORRECT +python3 -c " +import os +print(os.getcwd()) +" +``` + +--- + +## Stratégie de modification de fichiers Python + +### Méthode recommandée — Script Python de patch + +Créer un script `.py` dédié téléchargé via l'app mobile Claude.ai. + +```python +import ast, shutil, re + +SRC = "render_engine.py" +BAK = SRC + ".bak" +shutil.copy2(SRC, BAK) + +with open(SRC) as f: + c = f.read() + +# Nettoyage tirets unicode (obligatoire) +c = re.sub(r'[─]+', '-', c) + +OLD = """...texte exact apres nettoyage...""" +NEW = """...nouveau texte...""" + +if OLD in c: + c = c.replace(OLD, NEW) + print("OK") +else: + print("ECHEC -- pattern non trouve") + +with open(SRC, "w") as f: + f.write(c) + +# Valider le fichier final (pas la variable) +with open(SRC) as f: + final = f.read() +try: + ast.parse(final) + print(f"SYNTAXE OK -- {len(final.splitlines())} lignes") +except SyntaxError as e: + print(f"SYNTAXE ERREUR : {e}") + shutil.copy2(BAK, SRC) + print("Backup restaure.") +``` + +### Méthode par numéros de ligne (si pattern texte fragile) + +```python +import re + +with open("render_engine.py") as f: + lines = f.readlines() + +# Trouver par marqueur fiable +start = next(i for i, l in enumerate(lines) if "def ma_fonction(" in l) +end = next(i for i in range(start+1, len(lines)) if re.match(r'^ def ', lines[i])) + +new_func = """ def ma_fonction(...):\n ...\n\n""" +lines = lines[:start] + [new_func] + lines[end:] +``` + +--- + +## Vérifications utiles + +```bash +# Verifier qu'un patch est applique +grep -n "mot_cle_unique" render_engine.py + +# Lire les lignes exactes avant de patcher +sed -n '360,380p' render_engine.py + +# Compter les lignes +wc -l render_engine.py + +# Lancer un rendu de test +source venv/bin/activate && python3 render_engine.py output/sliding_20260518_220833_input.yaml output/test.pptx +``` + +--- + +## Règles de sécurité — fichiers YAML + +**Ne jamais écraser** `theme.yaml`, `layouts.yaml`, `components.yaml`. +Ces fichiers font 15 Ko, 45 Ko et plusieurs Ko. +Un LLM qui génère une version minimaliste casse tout le pipeline. + +Si modification nécessaire : +1. `cp theme.yaml theme.yaml.bak` +2. Script Python ciblé sur la clé concernée uniquement +3. `ls -lh *.yaml` pour vérifier la taille après + +En cas d'écrasement : originaux disponibles dans les outputs Claude.ai du projet. + +--- + +## Téléchargement depuis Claude.ai + +La webapp desktop échoue sur les fichiers > quelques Ko. +**Toujours utiliser l'app mobile Claude.ai** pour télécharger les scripts générés. + +--- + +## Structure des dossiers + +``` +~/App/Sliding/python-pptx/ + render_engine.py <- moteur rendu PPTX (2300+ lignes, diverge du fichier projet) + facilitator.py + prompt_injection.py + theme.yaml <- NE PAS ECRASER (15 Ko) + components.yaml <- NE PAS ECRASER + layouts.yaml <- NE PAS ECRASER (45 Ko) + assets/fonts/ <- Inter + Cormorant Garamond TTF + venv/ + output/ + +~/App/Context_continuity/ + trilium_context.py + +/volume1/docker/trilium/ <- donnees Trilium (chown 1000:1000) +/volume1/web/sliding/ <- galerie layouts HTML +/volume1/scripts/ <- scripts utilitaires +``` + +## Activation du venv + +```bash +source ~/App/Sliding/python-pptx/venv/bin/activate +``` + +--- + +## Outils disponibles sur GrosseBertha + +| Outil | Dispo | Note | +|---|---|---| +| python3 | oui | v3.9 | +| pip | oui | avec --break-system-packages | +| docker | oui | Container Manager 24.0.2 | +| wget | oui | avertissement HSTS ignorable | +| unzip | non | utiliser python3 zipfile | +| fc-list | non | detecter polices via assets/fonts/ | +| setfacl | non | pas disponible | +| fc-cache | non | pas disponible | +| pdftoppm | oui | poppler-utils installe | +| pdftotext | oui | poppler-utils installe | + +--- + +## Gestion Container Manager + +```bash +# Si Docker en erreur apres update DSM +sudo synosetkeyvalue /etc/synoinfo.conf unique synology_rtd1296_ds220j +sudo synosetkeyvalue /etc.defaults/synoinfo.conf unique synology_rtd1296_ds220j +sudo synopkg start ContainerManager + +# Trilium +sudo docker ps +sudo docker logs trilium +sudo docker restart trilium +``` diff --git a/skill_synology_dev.md b/skill_synology_dev.md new file mode 100644 index 0000000..b0b9467 --- /dev/null +++ b/skill_synology_dev.md @@ -0,0 +1,217 @@ +# SKILL — Développement sur GrosseBertha (Synology) + versioning Forgejo + +Skill universel pour toute session de développement sur l'infrastructure de +Bastien. Couvre la connexion, les conventions de patch de code, et le +versioning git via Forgejo. Réutilisable pour n'importe quel projet hébergé +sur GrosseBertha. + +--- + +## 1. L'environnement + +**GrosseBertha** — Synology DS218, ARM64, DSM 7.3.2. +- Connexion : SSH, utilisateur `Master`. +- Shell : `/bin/sh` (PAS bash — pas de `[[ ]]`, pas de tableaux bash). +- Python : `python3` v3.9 (dans un venv par projet). + +Activation du venv (projet Sliding) : +``` +source ~/App/Sliding/python-pptx/venv/bin/activate +``` + +Outils disponibles : `python3` (3.9), `pip` (avec `--break-system-packages`), +`docker` (Container Manager), `wget`, `pdftoppm`, `pdftotext` (poppler-utils), +`git`. **Indisponibles** : `unzip` (utiliser `python3 zipfile`), `fc-list`, +`fc-cache`, `setfacl`. + +--- + +## 2. Règles de procédure — OBLIGATOIRES + +Ces règles limitent la consommation de tokens et les itérations inutiles. + +**2.1 — Accord avant tout code lourd.** Ne jamais générer un script de patch, +une fonction ou un fichier sans accord explicite. « Je génère ? » → attendre +« oui ». + +**2.2 — Commandes terminal sur UNE SEULE LIGNE.** Le shell SSH n'accepte pas +le multiligne fiablement. +``` +# CORRECT +python3 -c "import os; print(os.getcwd())" +# INCORRECT (multiligne) +``` + +**2.3 — Lire les lignes exactes avant de patcher.** Le fichier sur GrosseBertha +diverge souvent des fichiers de référence. Toujours : +``` +sed -n 'X,Yp' ~/App/Sliding/python-pptx/render_engine.py +``` +avant tout patch. Ne jamais supposer que le fichier distant correspond au +fichier projet. + +**2.4 — Nettoyage unicode avant patch.** Les fichiers contiennent des tirets +unicode `─` dans les commentaires. Toujours nettoyer en début de script de +patch, sinon les patterns de remplacement échouent : +```python +import re +c = re.sub(r'[─]+', '-', c) +``` + +**2.5 — Valider le FICHIER FINAL, pas la variable intermédiaire.** +```python +# CORRECT +with open(SRC) as f: final = f.read() +ast.parse(final) +# INCORRECT — content peut être une variable corrompue +ast.parse(content) +``` + +**2.6 — Feedback visuel : PNG d'UNE slide, pas le PDF complet.** Évite la +rasterisation multi-pages inutile. + +**2.7 — Résultat terminal minimal.** Bastien ne colle que l'essentiel : « OK » +ou les lignes de confirmation si succès ; uniquement le traceback si erreur. + +**2.8 — Téléchargement des scripts via l'app mobile Claude.ai.** La webapp +desktop échoue sur les fichiers de plus de quelques Ko. + +--- + +## 3. Patch de code Python — méthode de référence + +Script `.py` dédié, appliqué sur GrosseBertha : + +```python +import ast, shutil, re + +SRC = "render_engine.py" +BAK = SRC + ".bak" +shutil.copy2(SRC, BAK) + +with open(SRC, encoding="utf-8") as f: + c = f.read() + +c = re.sub(r'[─]+', '-', c) # nettoyage unicode obligatoire + +OLD = """...texte exact après nettoyage...""" +NEW = """...nouveau texte...""" + +if OLD in c: + c = c.replace(OLD, NEW) + print("OK") +else: + print("ECHEC -- pattern non trouve") + +with open(SRC, "w", encoding="utf-8") as f: + f.write(c) + +with open(SRC, encoding="utf-8") as f: # valider le fichier final + final = f.read() +try: + ast.parse(final) + print(f"SYNTAXE OK -- {len(final.splitlines())} lignes") +except SyntaxError as e: + shutil.copy2(BAK, SRC) + print(f"SYNTAXE ERREUR : {e} -- backup restaure") +``` + +**Méthode par numéros de ligne** (si le pattern texte est fragile) : repérer +par marqueur fiable (`def ma_fonction(`) et remplacer la tranche `lines[start:end]`. + +--- + +## 4. Sécurité — fichiers YAML de config + +**Ne jamais écraser** `theme.yaml` (15 Ko), `layouts.yaml` (45 Ko), +`components.yaml`. Un LLM qui génère une version minimaliste casse tout le +pipeline. Si modification nécessaire : +1. `cp theme.yaml theme.yaml.bak` +2. Script Python ciblé sur la clé concernée uniquement +3. `ls -lh *.yaml` pour vérifier la taille après + +Originaux de secours : dans les outputs Claude.ai du projet. + +--- + +## 5. Versioning — Forgejo + +Forgejo auto-hébergé en Docker. Accès : SSH port **2222** ou HTTPS port **3000** +via `forgejo.bertha-cloud.fr`. Dépôt principal : `Master/sliding-automation`. + +**Workflow de commit après chaque modification validée :** +``` +git add +git commit -m "type: description claire du changement" +git push +``` + +**Conventions de messages de commit :** +- `fix:` — correction de bug +- `feat:` — nouvelle fonctionnalité +- `refactor:` — nettoyage / restructuration sans changement de comportement +- `chore:` — maintenance (suppression de fichiers obsolètes, etc.) +- `docs:` — documentation + +**Archiver une version (tag) :** +``` +git tag -a v2.0 -m "Description de la version" +git push origin v2.0 +``` + +**`.gitignore` type** (ne jamais versionner secrets, venv, sorties) : +``` +.env +venv/ +__pycache__/ +*.pyc +projets/*/outputs/ +projets/*/inputs/ +projets/*/project_state.json +projets/*/journal.md +*.pptx +*.pdf +*.bak +*.bak_* +``` + +Principe : git remplace les dossiers `archive/`. Les anciennes versions vivent +dans l'historique et les tags, pas dans des copies de fichiers. + +--- + +## 6. Container Manager / Docker + +``` +# Si Docker en erreur après update DSM (workaround communauté DS218) +sudo synosetkeyvalue /etc/synoinfo.conf unique synology_rtd1296_ds220j +sudo synosetkeyvalue /etc.defaults/synoinfo.conf unique synology_rtd1296_ds220j +sudo synopkg start ContainerManager +# Conteneurs +sudo docker ps +sudo docker logs +sudo docker restart +``` + +--- + +## 7. Structure des dossiers + +``` +~/App/Sliding/python-pptx/ ← pipeline Sliding + render_engine_v2.py ← moteur de rendu (diverge du fichier projet) + facilitator_v9.py ← orchestrateur (version active) + prompt_injection_v2.py + theme_v2.yaml ← NE PAS ÉCRASER + layouts_v2.yaml ← NE PAS ÉCRASER + components_v2.yaml ← NE PAS ÉCRASER + projets//inputs|outputs/ + venv/ + +~/App/Context_continuity/ ← système de mémoire Trilium + trilium_context.py + trilium_api.py + +/volume1/docker/trilium/ ← données Trilium (chown 1000:1000) +/volume1/web/sliding/ ← galerie layouts HTML +``` diff --git a/skill_trilium_api_reference.md b/skill_trilium_api_reference.md new file mode 100644 index 0000000..4b02cd0 --- /dev/null +++ b/skill_trilium_api_reference.md @@ -0,0 +1,180 @@ +# SKILL — Référence technique : APIs Trilium & serveur MCP + +Référence d'implémentation du système Context Continuity. À sortir pour +débugger, étendre ou appeler directement les APIs. Le « quand/pourquoi » +comportemental est dans le skill de réflexe de continuité ; ici c'est le +« comment » technique. + +Trois voies d'accès à Trilium coexistent : **API REST FastAPI** (distante, +HTTPS), **ETAPI via wrapper Python** (locale, sur GrosseBertha), **MCP** +(pour agents Mistral). + +--- + +## 1. API REST FastAPI — accès distant (recommandé pour un LLM) + +Couche créée pour exposer Trilium en HTTPS (pallie l'absence d'accès direct). + +| Paramètre | Valeur | +|---|---| +| Base URL | `https://api-trilium.bertha-cloud.fr` | +| Auth header | `Authorization: ` (clé brute, **sans** `Bearer`) | +| Clés | Clé Claude / clé Le Chat — demander à Bastien ou lire dans `.env` | +| Doc Swagger | `https://api-trilium.bertha-cloud.fr/api/docs` | + +**Endpoints essentiels :** +| Action | Appel | +|---|---| +| Lire le contexte projet | `GET /api/contexte/{projet}?llm_cible=NomLLM` | +| Voir le backlog | `GET /api/backlog/{projet}` | +| Enregistrer une décision | `POST /api/decisions` | +| Enregistrer un historique | `POST /api/historique` | +| Créer une conversation | `POST /api/conversations` | +| Clôturer une session | `PATCH /api/conversations/{id}` | +| Marquer un backlog item | `PATCH /api/backlog/{id}` | + +**Exemples curl :** +```bash +# Lire le briefing de reprise +curl -s -H "Authorization: CLE" \ + "https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['briefing'])" + +# Enregistrer une décision +curl -s -X POST -H "Authorization: CLE" -H "Content-Type: application/json" \ + -d '{"projet":"SlidingAutomation","enonce":"Décision prise","justification":"Raison"}' \ + https://api-trilium.bertha-cloud.fr/api/decisions + +# Marquer un backlog item comme fait +curl -s -X PATCH -H "Authorization: CLE" -H "Content-Type: application/json" \ + -d '{"statut":"fait"}' \ + https://api-trilium.bertha-cloud.fr/api/backlog/NOTE_ID +``` + +--- + +## 2. ETAPI via wrapper Python — accès local (sur GrosseBertha) + +TriliumNext 0.95+ a restructuré ses routes : `POST /etapi/notes` retourne +*Router not found*. **Solution validée : `trilium-py 1.3.9`**, encapsulé dans +`~/App/Context_continuity/trilium_api.py`. + +``` +pip install trilium-py python-dotenv +``` +`.env` : +``` +TRILIUM_URL=http://localhost:4292 +TRILIUM_TOKEN= ETAPI> +``` +Header d'auth : `Authorization: token` (**sans** `Bearer` ; Bearer accepté +depuis 0.93 mais non requis). + +**Fonctions du wrapper `trilium_api.py` :** +| Fonction | Note | +|---|---| +| `check_api()` | Lève SystemExit si Trilium inaccessible | +| `create_note(parent_id, title, content=" ", note_type="text")` | `content=" "` obligatoire — jamais vide | +| `get_note_id(result)` | Extrait `noteId` du résultat de `create_note` | +| `get_note(note_id)` / `get_note_content(note_id)` | Récupère note / contenu HTML | +| `update_note_content(note_id, content)` | Met à jour le contenu | +| `search_notes(query, limit=50)` | Recherche **sans guillemets** autour du terme | +| `search_by_label(label, value="")` | Syntaxe interne `#label=value` | +| `find_note_by_title(title, parent_id="")` | Retourne `noteId` ou `None` | +| `set_label(note_id, name, value="")` | `isInheritable=False` requis en interne | +| `get_label_value(note_id, name)` | Retourne la valeur ou `None` | + +**Erreurs connues et fixes :** +| Erreur | Cause | Fix | +|---|---|---| +| `Router not found POST /etapi/notes` | Routing TriliumNext 0.95 | Utiliser `trilium-py` | +| `missing argument isInheritable` | `create_attribute()` trilium-py | Passer `isInheritable=False` | +| `Note content must be set` | Contenu vide refusé | Passer `content=" "` | +| Recherche avec guillemets → `[]` | Parser 0.95 | Chercher sans guillemets | +| `{status,code,message}` sur create | Note existe déjà / contenu vide | `find_note_by_title` avant + `content=" "` | + +IDs des dossiers : `trilium_ids.json` (généré par `trilium_init.py`). Clés : +`root, Projets, Conversations, Backlog, Decisions, Historique, Glossaire, +ContextesReprise`. + +--- + +## 3. Serveur MCP — pour agents Mistral + +`mcp_server.py` — serveur MCP pur Starlette (compatible Python 3.9), transport +Streamable HTTP sur `/mcp`, écoute `127.0.0.1:8766`, exposé via +`https://mcp-trilium.bertha-cloud.fr`. Protocole `2025-06-18`. + +**Authentification : Bearer token statique** (`API_KEY_CLAUDE` / +`API_KEY_LECHAT` depuis `.env`). C'est ce qui le rend **utilisable par Le Chat +mais PAS par Claude** : les connecteurs distants de Claude.ai exigent OAuth +(endpoints de découverte `/.well-known/oauth-authorization-server`, flux +d'autorisation), absents de ce serveur. Pour activer Claude, il faudrait +ajouter une couche OAuth devant le serveur. + +**11 tools exposés :** `get_backlog`, `get_decisions`, `get_historique`, +`get_glossaire`, `get_contexte`, `add_decision`, `add_history`, `add_backlog`, +`update_backlog`, `new_conversation`, `close_session`. + +--- + +## 4. Structure des données Trilium + +Note racine **Context Continuity** → 8 sous-dossiers : `Projets`, +`Conversations`, `Backlog`, `Decisions`, `Historique`, `Glossaire`, +`Contextes Reprise`, `Skills`. + +**Types de notes et labels obligatoires :** + +| Type (`type=`) | Labels obligatoires | +|---|---| +| `projet` | `projet` (sans espaces), `statut` (actif\|en-pause\|archive) | +| `conversation` | `projet`, `llm`, `statut` (en-cours\|clos), `date` (YYYY-MM-DD HH:MM) | +| `backlogItem` | `projet`, `statut` (a faire\|en cours\|bloque\|fait\|abandonne), `priorite` (haute\|moyenne\|basse) | +| `decision` | `projet`, `statut` (active\|revisee\|annulee) | +| `historiqueItem` | `projet`, `typeHistorique`, `encoreValide` (true\|false) | +| `termeGlossaire` | `projet`, `definition` | +| `contexteReprise` | `projet`, `llmCible`, `version` | + +`typeHistorique` ∈ { `Fait etabli`, `Test effectue`, `Hypothese invalidee`, +`Contrainte decouverte` }. + +**Filtres des briefings** (pièges) : +- `generate-context` n'inclut que les décisions `statut=active`. +- N'inclut que l'historique `encoreValide=true`. +- `list-backlog` exclut `statut` ∈ { `fait`, `abandonne` }. +- Pour retirer un élément du briefing : passer `statut=annulee` (décision) ou + `encoreValide=false` (historique) — ne pas supprimer la note. + +**Pièges d'écriture récurrents :** +| Erreur | Conséquence | Fix | +|---|---|---| +| Statut avec majuscule (`Actif`) | Le filtre rate la note | Toujours minuscules | +| Nom projet avec espaces | `search_by_label` échoue | `SlidingAutomation` | +| `encoreValide="True"` | Filtre cherche `true` | Minuscules | +| `definition` absent sur glossaire | Briefing affiche « (voir note) » | Ajouter le label | + +--- + +## 5. Workflow CLI (sur GrosseBertha) + +```bash +cd ~/App/Context_continuity && source venv/bin/activate +# Début +python trilium_context.py new-conversation --projet SlidingAutomation --llm "Claude Sonnet" --titre "..." +# Reprise +python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Claude Sonnet" --note-id ID --version N +# Au fil de l'eau +python trilium_context.py add-decision --projet SlidingAutomation --enonce "..." --justification "..." +python trilium_context.py add-history --projet SlidingAutomation --type "Test effectue" --enonce "..." --detail "..." +# Clôture +python trilium_context.py close-session --note-id ID --summary "..." +# État +python trilium_context.py list-backlog --projet SlidingAutomation +python trilium_context.py list-projects +``` + +Fichiers du système : `trilium_api.py` (wrapper, ne pas modifier), +`trilium_init.py` (init unique), `trilium_context.py` (workflow quotidien), +`trilium_logger.py` (intégration pipeline Sliding), `trilium_ids.json`, +`mcp_server.py`, `api_context.py` (API FastAPI). diff --git a/skill_trilium_continuity.md b/skill_trilium_continuity.md new file mode 100644 index 0000000..d3670da --- /dev/null +++ b/skill_trilium_continuity.md @@ -0,0 +1,142 @@ +# SKILL — Réflexe de continuité Trilium + +Skill universel de mémoire de projet. Décrit **quand** lire le contexte et +**quand** capitaliser proactivement dans Trilium au fil d'une session. +Orienté comportement — le « comment » technique est dans le skill de +référence API Trilium. + +Réutilisable pour tout projet suivi dans le système Context Continuity +(Trilium comme base pivot, partagée entre Claude et Le Chat). + +--- + +## Le principe + +Trilium est la mémoire longue du projet, partagée entre LLMs. Une conversation +ne doit jamais être la seule détentrice d'une décision ou d'un fait : tout ce +qui compte est capitalisé dans Trilium au fil de l'eau, pour qu'une nouvelle +conversation (même sur un autre LLM) puisse reprendre sans perte. + +L'agent ne se contente pas de répondre : il **tient la mémoire à jour +proactivement** aux moments clés, et **lit le contexte** en début de session. + +--- + +## Deux chemins d'accès selon le LLM + +**Côté Le Chat (Mistral)** : accès **natif via MCP**. Les tools +(`get_contexte`, `add_decision`, `add_history`, etc.) sont appelés directement +par l'agent. + +**Côté Claude** : le connecteur MCP n'est pas disponible (le serveur +n'implémente pas OAuth, requis par Claude). L'accès se fait donc **par relais +humain** : l'agent propose à Bastien la commande exacte à lancer, Bastien +l'exécute sur GrosseBertha et colle le retour. C'est transparent côté +comportement — seul le mécanisme diffère. + +> Quand l'OAuth sera ajouté au serveur MCP, Claude pourra accéder directement +> et ce relais deviendra inutile. + +--- + +## EN DÉBUT DE SESSION — lire le contexte + +Au démarrage d'une session sur un projet existant, récupérer le briefing de +reprise avant de travailler : + +- **Le Chat** : appeler le tool `get_contexte(projet="SlidingAutomation", llm_cible="Le Chat Large")`. +- **Claude** : proposer à Bastien : + ``` + python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Claude Sonnet" --note-id --version + ``` + puis lire le briefing qu'il colle. + +Le briefing contient : décisions actives, historique valide, glossaire, +backlog actif. **Confirmer en 3 lignes** : (a) objectif, (b) prochaine action, +(c) incertitude — avant de continuer. + +Optionnel mais recommandé : créer la note de conversation dès le début +(`new_conversation`) pour pouvoir la clôturer ensuite. + +--- + +## PENDANT LA SESSION — capitaliser aux moments clés + +L'agent surveille activement ces déclencheurs et propose la capitalisation +**sans attendre la fin** : + +### Déclencheur : une décision est actée +Dès qu'un choix technique ou architectural est tranché (« on fait X plutôt +que Y ») : +- **Le Chat** : `add_decision(projet, enonce, justification)` +- **Claude** : proposer + ``` + python trilium_context.py add-decision --projet SlidingAutomation --enonce "Énoncé court et actionnable" --justification "Pourquoi ce choix" + ``` + +### Déclencheur : un test est effectué ou une contrainte découverte +Dès qu'un test valide/invalide quelque chose, ou qu'une limite est rencontrée : +- **Le Chat** : `add_history(projet, enonce, type_historique, detail)` +- **Claude** : proposer + ``` + python trilium_context.py add-history --projet SlidingAutomation --type "Test effectue" --enonce "Ce qui a été testé" --detail "Résultat observé" + ``` +`type_historique` ∈ { `Fait etabli`, `Test effectue`, `Hypothese invalidee`, +`Contrainte decouverte` } (sans accent, exactement ces libellés). + +### Déclencheur : une tâche future émerge +Dès qu'un « il faudra faire X » apparaît : +- **Le Chat** : `add_backlog(projet, titre, priorite)` +- **Claude** : proposer la commande équivalente. +`priorite` ∈ { `haute`, `moyenne`, `basse` }. + +### Déclencheur : une tâche change d'état +Quand un item passe en cours / fait / bloqué : `update_backlog(note_id, statut)`. +`statut` ∈ { `a faire`, `en cours`, `bloque`, `fait`, `abandonne` }. + +--- + +## EN FIN DE SESSION — clôturer + +Quand Bastien signale que la limite de tokens approche (l'agent se répète, +oublie une contrainte, perd en précision), produire une **synthèse de clôture** +de 150-200 mots, format concis sans markdown complexe : +``` +Synthèse de clôture : +- Objectif de la session : ... +- Accompli : ... +- En cours : ... +- Bloqué : ... +- Prochaine action : ... +``` +Puis l'enregistrer : +- **Le Chat** : `close_session(note_id, synthese)` +- **Claude** : proposer + ``` + python trilium_context.py close-session --note-id --summary "COLLE ICI LA SYNTHÈSE" + ``` + +Enfin, générer le briefing de reprise (`generate-context`) pour la prochaine +session ou la bascule vers l'autre LLM. + +--- + +## Règles de capitalisation — à respecter strictement + +Ces règles évitent que les filtres de Trilium ratent les notes : + +- **Nom de projet sans espaces** : `SlidingAutomation`, jamais `Sliding Automation`. +- **Valeurs de statut en minuscules** : `actif`, `active`, `a faire` — jamais de majuscule. +- **`encoreValide`** : `true` / `false` en minuscules. +- **`typeHistorique`** : exactement un des 4 libellés sans accent. +- Énoncés **courts et actionnables** — une ligne, pas un paragraphe. + +--- + +## Posture de l'agent + +Capitaliser n'est pas optionnel ni réservé à la fin. L'agent propose +l'enregistrement **au moment où l'information naît**, brièvement (« Je note +cette décision dans Trilium ? » + la commande prête). Il n'attend pas que +Bastien le demande. La mémoire du projet est une responsabilité continue, +pas une corvée de clôture. diff --git a/start_api.sh b/start_api.sh new file mode 100644 index 0000000..98653cf --- /dev/null +++ b/start_api.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd /volume1/homes/Master/App/Context_continuity +pkill -f "uvicorn api_context:app" 2>/dev/null +sleep 2 +source venv/bin/activate +uvicorn api_context:app --host 127.0.0.1 --port 8765 >> /volume1/homes/Master/App/Context_continuity/api.log 2>&1 & +echo $! > /volume1/homes/Master/App/Context_continuity/api.pid diff --git a/start_mcp.sh b/start_mcp.sh new file mode 100644 index 0000000..43fc2c8 --- /dev/null +++ b/start_mcp.sh @@ -0,0 +1,8 @@ +#!/bin/bash +cd /volume1/homes/Master/App/Context_continuity +# Tuer tout uvicorn mcp_server existant (evite les zombies sur le port 8766) +pkill -f "uvicorn mcp_server:app" 2>/dev/null +sleep 2 +source venv/bin/activate +uvicorn mcp_server:app --host 127.0.0.1 --port 8766 >> /volume1/homes/Master/App/Context_continuity/mcp.log 2>&1 & +echo $! > /volume1/homes/Master/App/Context_continuity/mcp.pid diff --git a/synthesis_context_cintinuity.md b/synthesis_context_cintinuity.md new file mode 100644 index 0000000..acd5a87 --- /dev/null +++ b/synthesis_context_cintinuity.md @@ -0,0 +1,187 @@ +# Synthèse - Projet Context Continuity +# Date : 27 mai 2026 +# Participants : Bastien Gourdon, Claude, Le Chat (Mistral) + +--- + +## Contexte du Projet +**Objectif principal** : Créer un système de **gestion de contexte partagé** entre plusieurs LLM (Claude, Le Chat) pour dépasser les limites de tokens (≤ 10K) et centraliser les informations liées aux projets (ex: Sliding Automation, Data Governance). + +**Outils utilisés** : +- **Trilium** : Base de connaissances self-hosted (Docker sur Synology DS218). +- **Synology DS218 (GrosseBertha)** : Hébergement de Trilium et des scripts. +- **Reverse Proxy** : Accès sécurisé à Trilium via `https://trilium.bertha-cloud.fr`. +- **API ETAPI** : Interaction programmatique avec Trilium. + +--- + +## ✅ Décisions Prises + +### 1. **Abandon d’Anytype au profit de Trilium** +- **Raison** : + - Anytype est une application desktop (Electron) **non utilisable en headless** sur NAS. + - Trilium est **100% web**, self-hosted, avec une **API REST (ETAPI)** documentée. + - Données **100% self-hosted** sur GrosseBertha (SQLite dans `/volume1/docker/trilium/`). + - **Licence AGPL-3.0** et communauté active. + +### 2. **Architecture de Trilium** +- **Arborescence validée** : + ``` + Context Continuity/ + ├── Conversations/ # Notes de type "Conversation LLM" + ├── Backlog/ # Tâches à faire + ├── Décisions/ # Décisions prises + ├── Glossaire/ # Termes techniques + └── Historique/ # Historique des actions + ``` +- **Types de notes** : + - **Book** : Pour les dossiers (ex: `Conversations/`). + - **Text** : Pour les notes de contenu (ex: une conversation spécifique). + +### 3. **Automatisation via Scripts Python** +- **Objectif** : Synchroniser automatiquement les contextes entre LLM et Trilium. +- **Scripts créés** : + - `init_trilium_architecture.py` : Initialise l’arborescence. + - `trilium_api.py` : Bibliothèque pour interagir avec l’API ETAPI. + - `create_conversation.py` : Crée des notes de conversation. + - `generate_context.py` : Génère un contexte pour les LLM. + +### 4. **Configuration Technique** +- **Reverse Proxy** : + - **Source** : `https://trilium.bertha-cloud.fr:443` (HTTPS). + - **Destination** : `http://192.168.1.6:4292` (HTTP vers le conteneur Docker). + - **Certificat SSL** : Let’s Encrypt (géré via DSM). +- **Docker** : + - Image : `triliumnext/notes:latest`. + - Port : `4292:8080`. + - Volume : `/volume1/docker/trilium:/home/node/trilium-data`. + +--- + +## Statut des Tâches + +### ✅ Tâches Effectuées +| Tâche | Statut | Détails | +|-------|--------|---------| +| **Installation de Trilium** | ✅ Terminé | Conteneur Docker lancé et accessible en local (`http://192.168.1.6:4292`). | +| **Reverse Proxy** | ✅ Terminé | Configuré dans DSM pour `trilium.bertha-cloud.fr`. | +| **Certificat SSL** | ✅ Terminé | Let’s Encrypt configuré via DSM. | +| **Accès externe** | ✅ Terminé | `https://trilium.bertha-cloud.fr` fonctionne. | +| **Token ETAPI** | ⏳ En attente | À générer dans Trilium (Options → ETAPI → Create new token). | +| **Scripts Python** | ✅ Prêts | `init_trilium_architecture.py`, `trilium_api.py`, etc. | + +### Tâches en Cours +| Tâche | Statut | Prochaine Étape | +|-------|--------|-----------------| +| **Initialisation de l’arborescence** | ⏳ En attente | Exécuter `init_trilium_architecture.py`. | +| **Test des scripts** | ⏳ En attente | Vérifier que les notes sont créées dans Trilium. | +| **Intégration avec les LLM** | ⏳ En attente | Utiliser `generate_context.py` avant chaque session LLM. | + +### ❌ Tâches Restantes +| Tâche | Priorité | Dépendances | +|-------|----------|-------------| +| **Générer le token ETAPI** | ⭐⭐⭐ | Nécessaire pour les scripts Python. | +| **Exécuter `init_trilium_architecture.py`** | ⭐⭐⭐ | Nécessite le token ETAPI. | +| **Créer la première conversation** | ⭐⭐ | Nécessite l’arborescence Trilium. | +| **Tester le workflow complet** | ⭐⭐ | Nécessite les scripts et l’arborescence. | +| **Automatiser la synchronisation** | ⭐ | Nécessite un cron job ou un déclencheur manuel. | + +--- + +## Tests à Effectuer + +### 1. **Test de l’API ETAPI** +```bash +# Remplace TON_TOKEN par ton token ETAPI +curl -H "Authorization: TON_TOKEN" http://localhost:4292/etapi/notes +``` +**Résultat attendu** : Une liste de notes au format JSON. + +### 2. **Test du Reverse Proxy** +- Ouvre `https://trilium.bertha-cloud.fr` dans un navigateur. +**Résultat attendu** : Accès à Trilium sans erreur SSL. + +### 3. **Test des Scripts Python** +```bash +# Initialiser l'arborescence +python3 init_trilium_architecture.py + +# Créer une conversation +python3 create_conversation.py + +# Générer un contexte +python3 generate_context.py > contexte.md +``` +**Résultat attendu** : +- Dossiers créés dans Trilium. +- Note de conversation ajoutée. +- Fichier `contexte.md` généré. + +--- + +## Prochaines Étapes avec Claude + +### 1. **Finaliser la Configuration de Trilium** +- [ ] **Générer le token ETAPI** dans Trilium (Options → ETAPI → Create new token). +- [ ] **Exécuter `init_trilium_architecture.py`** pour créer l’arborescence. +- [ ] **Vérifier les IDs des dossiers** dans `trilium_folder_ids.json`. + +### 2. **Tester les Scripts** +- [ ] **Tester `create_conversation.py`** avec une conversation factice. +- [ ] **Tester `generate_context.py`** pour générer un contexte. +- [ ] **Corriger les erreurs** si nécessaire (ex: permissions, token invalide). + +### 3. **Intégrer avec les LLM** +- [ ] **Avant une session LLM** : Exécuter `generate_context.py` pour récupérer le contexte. +- [ ] **Après une session LLM** : Exécuter `create_conversation.py` pour ajouter la conversation à Trilium. +- [ ] **Automatiser** avec un cron job ou un script wrapper. + +### 4. **Améliorations Futures** +- [ ] **Ajouter un système de versioning** pour les notes (ex: champ `version`). +- [ ] **Intégrer un LLM local** (ex: Mistral 7B) pour résumer les contextes. +- [ ] **Créer un dashboard** pour visualiser les projets et conversations. + +--- + +## Notes Techniques + +### 1. **Contraintes Synology** +- **Commandes terminal** : Toujours sur **1 seule ligne** (contrainte DS218). +- **Docker** : Utilise `Container Manager 24.0.2-1606` (script communautaire 007revad). +- **synoinfo.conf** : `unique=synology_rtd1296_ds220j` (à vérifier après les mises à jour DSM). + +### 2. **Sécurité** +- **Token ETAPI** : À conserver **secrétisé** (ne jamais le commiter dans Git). +- **Certificat SSL** : Let’s Encrypt géré via DSM (renouvellement automatique). +- **Accès externe** : Vérifier que le **port 443** est ouvert sur la Livebox. + +### 3. **Performances** +- **Limite de tokens** : Briefing ≤ 10K tokens pour éviter les coûts excessifs. +- **API Trilium** : Éviter les requêtes trop fréquentes (limite à 10 notes par appel dans `generate_context.py`). + +--- + +## Liens Utiles +- **Trilium** : [https://trilium.bertha-cloud.fr](https://trilium.bertha-cloud.fr) +- **Documentation ETAPI** : [http://localhost:4292/etapi/](http://localhost:4292/etapi/) +- **Docker Trilium** : [https://hub.docker.com/r/triliumnext/notes](https://hub.docker.com/r/triliumnext/notes) + +--- + +## Calendrier Estimé +| Étape | Durée | Priorité | +|-------|-------|----------| +| Générer le token ETAPI | 5 min | ⭐⭐⭐ | +| Exécuter `init_trilium_architecture.py` | 10 min | ⭐⭐⭐ | +| Tester les scripts | 30 min | ⭐⭐⭐ | +| Intégrer avec les LLM | 1h | ⭐⭐ | +| Automatiser la synchronisation | 1h | ⭐ | + +--- + +## Points à Discuter avec Claude +1. **Validation du token ETAPI** : Confirmer que le token est bien généré et fonctionnel. +2. **Tests des scripts** : Vérifier que `init_trilium_architecture.py` crée bien l’arborescence. +3. **Intégration LLM** : Comment partager le contexte généré avec Claude/Le Chat ? +4. **Améliorations** : Faut-il ajouter des champs spécifiques (ex: `priorité`, `tags`) aux notes ? +5. **Automatisation** : Comment déclencher les scripts (cron, manuel, autre) ? \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..e64df4b --- /dev/null +++ b/test_api.py @@ -0,0 +1,310 @@ +""" +test_api.py — Tests unitaires FastAPI Context Continuity +Usage : pytest test_api.py -v +""" +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from api_context import app + +CLIENT = TestClient(app) +HEADERS_CLAUDE = {"Authorization": "VyFeOxT1nfyPULlx4z5uts7p5R_RrJIOZ3y_f1aoMk8"} +HEADERS_LECHAT = {"Authorization": "WE2gw12Eerz0H-kV6SHNDAq-NN9Z6I81KAq3T2giNME"} +HEADERS_INVALID = {"Authorization": "mauvaise_cle"} + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +NOTE_MOCK = { + "noteId": "test123", + "title": "Note Test", + "utcDateCreated": "2026-05-30T10:00:00Z", + "utcDateModified": "2026-05-30T10:00:00Z", + "attributes": [ + {"type": "label", "name": "type", "value": "backlogItem"}, + {"type": "label", "name": "projet", "value": "TestProjet"}, + {"type": "label", "name": "statut", "value": "a faire"}, + {"type": "label", "name": "priorite","value": "haute"}, + ], + "childNoteIds": [], + "parentNoteIds": ["root"], +} + +CREATE_RESULT = { + "note": {"noteId": "new456", "title": "Nouvelle Note", + "utcDateCreated": "2026-05-30T10:00:00Z", + "utcDateModified": "2026-05-30T10:00:00Z", + "attributes": [], "childNoteIds": [], "parentNoteIds": []}, + "branch": {} +} + +# --------------------------------------------------------------------------- +# Tests Auth +# --------------------------------------------------------------------------- + +class TestAuth: + def test_health_no_auth(self): + """Health ne nécessite pas d'auth.""" + r = CLIENT.get("/api/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + def test_protected_no_auth(self): + r = CLIENT.get("/api/backlog/TestProjet") + assert r.status_code == 422 # Header manquant + + def test_protected_invalid_key(self): + r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_INVALID) + assert r.status_code == 401 + + def test_auth_claude(self): + with patch("trilium_api.search_by_label", return_value=[]), \ + patch("trilium_api.get_label_value", return_value="a faire"): + r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_CLAUDE) + assert r.status_code == 200 + + def test_auth_lechat(self): + with patch("trilium_api.search_by_label", return_value=[]), \ + patch("trilium_api.get_label_value", return_value="a faire"): + r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_LECHAT) + assert r.status_code == 200 + +# --------------------------------------------------------------------------- +# Tests Health +# --------------------------------------------------------------------------- + +class TestHealth: + def test_health(self): + r = CLIENT.get("/api/health") + assert r.status_code == 200 + data = r.json() + assert data["status"] == "ok" + assert "version" in data + +# --------------------------------------------------------------------------- +# Tests Backlog +# --------------------------------------------------------------------------- + +class TestBacklog: + def test_list_backlog_vide(self): + with patch("trilium_api.search_by_label", return_value=[]), \ + patch("trilium_api.get_label_value", return_value="a faire"): + r = CLIENT.get("/api/backlog/ProjetInexistant", headers=HEADERS_CLAUDE) + assert r.status_code == 200 + assert r.json() == [] + + def test_list_backlog(self): + with patch("trilium_api.search_by_label", return_value=[NOTE_MOCK]), \ + patch("trilium_api.get_label_value", side_effect=lambda nid, key: { + "projet": "TestProjet", "statut": "a faire", "priorite": "haute" + }.get(key, "")), \ + patch("trilium_api.get_note", return_value=NOTE_MOCK): + r = CLIENT.get("/api/backlog/TestProjet", headers=HEADERS_CLAUDE) + assert r.status_code == 200 + + def test_create_backlog(self): + with patch("builtins.open", MagicMock( + return_value=MagicMock( + __enter__=MagicMock(return_value=MagicMock( + read=MagicMock(return_value='{"Backlog":"root123"}'))), + __exit__=MagicMock(return_value=False)))), \ + patch("json.load", return_value={"Backlog": "root123"}), \ + patch("trilium_api.create_note", return_value=CREATE_RESULT), \ + patch("trilium_api.get_note_id", return_value="new456"), \ + patch("trilium_api.set_label", return_value=None): + r = CLIENT.post("/api/backlog", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", "titre": "Ma tâche", + "priorite": "haute"}) + assert r.status_code == 201 + assert r.json()["titre"] == "Ma tâche" + assert r.json()["created_by"] == "Claude" + + def test_create_backlog_priorite_invalide(self): + r = CLIENT.post("/api/backlog", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", "titre": "Tâche", + "priorite": "ULTRA"}) + assert r.status_code == 422 + + def test_patch_backlog(self): + with patch("trilium_api.get_note", return_value=NOTE_MOCK), \ + patch("trilium_api.set_label", return_value=None): + r = CLIENT.patch("/api/backlog/test123", headers=HEADERS_CLAUDE, + json={"statut": "en cours"}) + assert r.status_code == 200 + + def test_patch_backlog_inexistant(self): + with patch("trilium_api.get_note", side_effect=Exception("404")): + r = CLIENT.patch("/api/backlog/inexistant", headers=HEADERS_CLAUDE, + json={"statut": "fait"}) + assert r.status_code == 404 + + def test_patch_backlog_statut_invalide(self): + r = CLIENT.patch("/api/backlog/test123", headers=HEADERS_CLAUDE, + json={"statut": "statut_invalide"}) + assert r.status_code == 422 + +# --------------------------------------------------------------------------- +# Tests Décisions +# --------------------------------------------------------------------------- + +class TestDecisions: + def test_list_decisions_vide(self): + with patch("trilium_api.search_by_label", return_value=[]), \ + patch("trilium_api.get_label_value", return_value="active"): + r = CLIENT.get("/api/decisions/TestProjet", headers=HEADERS_CLAUDE) + assert r.status_code == 200 + assert r.json() == [] + + def test_create_decision(self): + with patch("json.load", return_value={"Decisions": "dec123"}), \ + patch("builtins.open", MagicMock( + return_value=MagicMock( + __enter__=MagicMock(return_value=MagicMock()), + __exit__=MagicMock(return_value=False)))), \ + patch("trilium_api.create_note", return_value=CREATE_RESULT), \ + patch("trilium_api.get_note_id", return_value="new456"), \ + patch("trilium_api.set_label", return_value=None): + r = CLIENT.post("/api/decisions", headers=HEADERS_LECHAT, + json={"projet": "TestProjet", + "enonce": "On utilise FastAPI", + "justification": "Validation auto Pydantic"}) + assert r.status_code == 201 + assert r.json()["created_by"] == "LeChat" + + def test_patch_decision_annuler(self): + with patch("trilium_api.get_note", return_value=NOTE_MOCK), \ + patch("trilium_api.set_label", return_value=None), \ + patch("trilium_api.get_note_content", return_value="

contenu

"), \ + patch("trilium_api.update_note_content", return_value=None): + r = CLIENT.patch("/api/decisions/test123", headers=HEADERS_CLAUDE, + json={"statut": "annulee"}) + assert r.status_code == 200 + +# --------------------------------------------------------------------------- +# Tests Historique +# --------------------------------------------------------------------------- + +class TestHistorique: + def test_create_historique_valide(self): + with patch("json.load", return_value={"Historique": "hist123"}), \ + patch("builtins.open", MagicMock( + return_value=MagicMock( + __enter__=MagicMock(return_value=MagicMock()), + __exit__=MagicMock(return_value=False)))), \ + patch("trilium_api.create_note", return_value=CREATE_RESULT), \ + patch("trilium_api.get_note_id", return_value="new456"), \ + patch("trilium_api.set_label", return_value=None): + r = CLIENT.post("/api/historique", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", + "enonce": "Test X effectue", + "type": "Test effectue", + "detail": "Resultat OK"}) + assert r.status_code == 201 + + def test_create_historique_type_invalide(self): + r = CLIENT.post("/api/historique", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", + "enonce": "Test", + "type": "Type invalide"}) + assert r.status_code == 422 + + def test_patch_invalider(self): + with patch("trilium_api.get_note", return_value=NOTE_MOCK), \ + patch("trilium_api.set_label", return_value=None), \ + patch("trilium_api.get_note_content", return_value="

ok

"), \ + patch("trilium_api.update_note_content", return_value=None): + r = CLIENT.patch("/api/historique/test123", headers=HEADERS_CLAUDE, + json={"encore_valide": False}) + assert r.status_code == 200 + +# --------------------------------------------------------------------------- +# Tests Conversations +# --------------------------------------------------------------------------- + +class TestConversations: + def test_create_conversation(self): + with patch("json.load", return_value={"Conversations": "conv123"}), \ + patch("builtins.open", MagicMock( + return_value=MagicMock( + __enter__=MagicMock(return_value=MagicMock()), + __exit__=MagicMock(return_value=False)))), \ + patch("trilium_api.create_note", return_value=CREATE_RESULT), \ + patch("trilium_api.get_note_id", return_value="new456"), \ + patch("trilium_api.set_label", return_value=None): + r = CLIENT.post("/api/conversations", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", + "titre": "Session debug", + "llm": "Claude Sonnet"}) + assert r.status_code == 201 + + def test_create_conversation_llm_invalide(self): + r = CLIENT.post("/api/conversations", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", + "titre": "Session", + "llm": "GPT-4"}) + assert r.status_code == 422 + + def test_patch_cloture(self): + with patch("trilium_api.get_note", return_value=NOTE_MOCK), \ + patch("trilium_api.set_label", return_value=None), \ + patch("trilium_api.get_note_content", + return_value="À remplir en fin de session"), \ + patch("trilium_api.update_note_content", return_value=None): + r = CLIENT.patch("/api/conversations/test123", headers=HEADERS_CLAUDE, + json={"synthese_cloture": "Session terminee. Reste X."}) + assert r.status_code == 200 + +# --------------------------------------------------------------------------- +# Tests Glossaire +# --------------------------------------------------------------------------- + +class TestGlossaire: + def test_create_terme(self): + with patch("json.load", return_value={"Glossaire": "glos123"}), \ + patch("builtins.open", MagicMock( + return_value=MagicMock( + __enter__=MagicMock(return_value=MagicMock()), + __exit__=MagicMock(return_value=False)))), \ + patch("trilium_api.create_note", return_value=CREATE_RESULT), \ + patch("trilium_api.get_note_id", return_value="new456"), \ + patch("trilium_api.set_label", return_value=None): + r = CLIENT.post("/api/glossaire", headers=HEADERS_CLAUDE, + json={"projet": "TestProjet", + "terme": "Pipeline", + "definition": "Chaine de traitement complète"}) + assert r.status_code == 201 + +# --------------------------------------------------------------------------- +# Tests Contexte Reprise +# --------------------------------------------------------------------------- + +class TestContexteReprise: + def test_get_contexte_vide(self): + with patch("trilium_api.search_by_label", return_value=[]), \ + patch("trilium_api.get_label_value", return_value="active"): + r = CLIENT.get("/api/contexte/ProjetVide", + headers=HEADERS_CLAUDE) + assert r.status_code == 200 + assert "briefing" in r.json() + assert "tokens" in r.json() + + def test_get_last_contexte_inexistant(self): + with patch("trilium_api.search_by_label", return_value=[]), \ + patch("trilium_api.get_label_value", return_value=None): + r = CLIENT.get("/api/contexte/ProjetVide/last", + headers=HEADERS_CLAUDE) + assert r.status_code == 404 + +# --------------------------------------------------------------------------- +# Tests Docs +# --------------------------------------------------------------------------- + +class TestDocs: + def test_swagger_accessible(self): + r = CLIENT.get("/api/docs") + assert r.status_code == 200 + + def test_openapi_accessible(self): + r = CLIENT.get("/api/openapi.json") + assert r.status_code == 200 diff --git a/trilium_api.py b/trilium_api.py new file mode 100644 index 0000000..7f2bb6b --- /dev/null +++ b/trilium_api.py @@ -0,0 +1,216 @@ +""" +trilium_api.py — Wrapper trilium-py pour TriliumNext 0.95+ +Tous les autres scripts importent depuis ce module uniquement. +""" +import os +from typing import Optional +from dotenv import load_dotenv +from trilium_py.client import ETAPI + +load_dotenv() + +_TRILIUM_URL = os.getenv("TRILIUM_URL", "http://localhost:4292") +_TRILIUM_TOKEN = os.getenv("TRILIUM_TOKEN", "") + +ea: Optional[ETAPI] = None + +def check_api(): + global ea + try: + ea = ETAPI(_TRILIUM_URL, _TRILIUM_TOKEN) + result = ea.get_note("root") + if not result or "noteId" not in result: + raise ConnectionError("Réponse inattendue") + return True + except Exception as e: + raise SystemExit( + f"\n❌ Trilium inaccessible sur {_TRILIUM_URL}" + f"\n Détail : {e}" + "\n sudo docker ps | grep trilium" + "\n sudo docker start trilium\n" + ) + +def _ea() -> ETAPI: + global ea + if ea is None: + ea = ETAPI(_TRILIUM_URL, _TRILIUM_TOKEN) + return ea + +def _note_id(result: dict) -> str: + """Extrait le noteId quel que soit le format retourné par trilium-py.""" + if "note" in result: + return result["note"]["noteId"] + if "noteId" in result: + return result["noteId"] + raise ValueError(f"Format inattendu : {list(result.keys())}") + +# --------------------------------------------------------------------------- +# Notes +# --------------------------------------------------------------------------- + +def create_note(parent_id: str, title: str, content: str = " ", + note_type: str = "text") -> dict: + return _ea().create_note( + parentNoteId=parent_id, title=title, + type=note_type, content=content + ) + +def get_note_id(result: dict) -> str: + """Helper public pour extraire le noteId d'un résultat create_note.""" + return _note_id(result) + +def get_note(note_id: str) -> dict: + return _ea().get_note(note_id) + +def get_note_content(note_id: str) -> str: + result = _ea().get_note_content(note_id) + if isinstance(result, bytes): + return result.decode("utf-8") + return result or "" + +def update_note_content(note_id: str, content: str): + _ea().update_note_content(note_id, content) + +def get_children(note_id: str) -> list: + note = _ea().get_note(note_id) + return [{"noteId": cid} for cid in note.get("childNoteIds", [])] + +# --------------------------------------------------------------------------- +# Recherche +# --------------------------------------------------------------------------- + +def search_notes(query: str, limit: int = 50) -> list: + result = _ea().search_note(search=query, limit=limit) + return result.get("results", []) if isinstance(result, dict) else [] + +def search_by_label(label: str, value: str = "", limit: int = 50) -> list: + query = f"#{label}" if not value else f"#{label}={value}" + return search_notes(query, limit) + +def find_note_by_title(title: str, parent_id: str = "") -> Optional[str]: + for note in search_notes(title): + if note.get("title", "").strip().lower() == title.strip().lower(): + if not parent_id: + return note["noteId"] + detail = get_note(note["noteId"]) + if parent_id in detail.get("parentNoteIds", []): + return note["noteId"] + return None + +# --------------------------------------------------------------------------- +# Attributs +# --------------------------------------------------------------------------- + +def set_label(note_id: str, name: str, value: str = ""): + """Cree ou met a jour un label via patch_attribute natif (pas d empilement).""" + ea = _ea() + note = get_note(note_id) + existants = [a for a in note.get("attributes", []) + if a.get("type") == "label" and a.get("name") == name] + if existants: + ea.patch_attribute(attributeId=existants[0].get("attributeId"), value=value) + else: + ea.create_attribute(noteId=note_id, type="label", + name=name, value=value, isInheritable=False) + +def get_label_value(note_id: str, name: str) -> Optional[str]: + note = get_note(note_id) + for a in note.get("attributes", []): + if a.get("type") == "label" and a.get("name") == name: + return a.get("value", "") + try: + attrs = _ea().get_note_attributes(note_id) + if isinstance(attrs, list): + for a in attrs: + if a.get("type") == "label" and a.get("name") == name: + return a.get("value", "") + except Exception: + pass + return None + + +TYPES_SYSTEME = ["backlogItem","decision","historiqueItem","conversation","termeGlossaire","projet","contexteReprise","skill","documentation"] + +def delete_note_safe(note_id: str): + """Supprime une note SEULEMENT si elle porte un label type connu du systeme. + Garde-fou contre la suppression accidentelle de dossiers/notes hors systeme. + Retourne (True, message) ou (False, message).""" + note = get_note(note_id) + if not note: + return (False, "Note introuvable") + type_val = None + for a in note.get("attributes", []): + if a.get("type") == "label" and a.get("name") == "type": + type_val = a.get("value") + break + if type_val not in TYPES_SYSTEME: + return (False, "Refus : note sans type systeme connu (type=%s)" % type_val) + titre = note.get("title", "?") + ok = _ea().delete_note(note_id) + if ok: + return (True, "Note supprimee : %s (%s)" % (titre, note_id)) + return (False, "Echec suppression cote Trilium") + + +def move_note_safe(note_id, ancien_parent_id, nouveau_parent_id): + """Deplace une note d un parent vers un autre (gere les clones : ne touche + que la branche depuis ancien_parent_id). Garde-fou leger : verifie l existence + de la note et du nouveau parent. Retourne (True, message) ou (False, message).""" + ea = _ea() + note = get_note(note_id) + if not note: + return (False, "Note introuvable: %s" % note_id) + if not get_note(nouveau_parent_id): + return (False, "Nouveau parent introuvable: %s" % nouveau_parent_id) + # Retrouver la branche reelle depuis l ancien parent + cible_branche = None + for bid in note.get("parentBranchIds", []): + if bid.startswith(ancien_parent_id + "_"): + cible_branche = bid + break + if not cible_branche: + return (False, "Aucune branche depuis le parent %s" % ancien_parent_id) + # Creer la nouvelle branche puis supprimer l ancienne + try: + ea.create_branch(noteId=note_id, parentNoteId=nouveau_parent_id, + prefix="", isExpanded=False, notePosition=10) + except Exception as e: + return (False, "Echec create_branch: %s" % e) + try: + ea.delete_branch(cible_branche) + except Exception as e: + return (False, "Branche creee mais echec suppression ancienne (%s): %s" % (cible_branche, e)) + return (True, "Note %s deplacee de %s vers %s" % (note_id, ancien_parent_id, nouveau_parent_id)) + + +# Relations autorisees par l ontologie (anti-drift). Le controle domaine/portee +# fin est delegue au Lint ; ici on bloque seulement les noms hors ontologie. +RELATIONS_ONTOLOGIE = [ + "concerneProjet", "meneePar", "priseDans", "produitDans", "synthetise", + "destineA", "hebergeSur", "implementePar", "dependDe", "aVersion", + "documentePar", "decrit", "impacte", "contraintPar", "revise", "invalide", + "reference", "illustre", "provient", +] + +def add_relation_safe(source_id, nom, cible_id): + """Cree une relation (object property) entre deux notes. + Garde-fou Option B : nom dans l ontologie + source/cible existent. + Idempotent : ne recree pas une relation identique deja presente. + Retourne (True, message) ou (False, message).""" + if nom not in RELATIONS_ONTOLOGIE: + return (False, "Relation hors ontologie: %s (voir RELATIONS_ONTOLOGIE)" % nom) + src = get_note(source_id) + if not src: + return (False, "Note source introuvable: %s" % source_id) + if not get_note(cible_id): + return (False, "Note cible introuvable: %s" % cible_id) + # Idempotence : relation identique deja la ? + for a in src.get("attributes", []): + if a.get("type") == "relation" and a.get("name") == nom and a.get("value") == cible_id: + return (True, "Relation deja presente: %s ~%s %s" % (source_id, nom, cible_id)) + try: + _ea().create_attribute(noteId=source_id, type="relation", + name=nom, value=cible_id, isInheritable=False) + except Exception as e: + return (False, "Echec creation relation: %s" % e) + return (True, "Relation creee: %s ~%s %s" % (source_id, nom, cible_id)) diff --git a/trilium_automation_setup.md b/trilium_automation_setup.md new file mode 100644 index 0000000..978b273 --- /dev/null +++ b/trilium_automation_setup.md @@ -0,0 +1,316 @@ +# Trilium Automation Setup - Fichier Unique avec Tous les Scripts +# Projet : Context Continuity +# Date : 27 mai 2026 + +--- + +## Description +Ce fichier contient tous les scripts Python et configurations nécessaires pour automatiser la gestion de contexte multi-LLM avec Trilium. + +--- + +## Structure des Fichiers +``` +trilium_automation/ +├── init_trilium_architecture.py # Initialise l'arborescence Trilium +├── trilium_api.py # Bibliothèque pour interagir avec l'API ETAPI +├── create_conversation.py # Crée des notes de conversation LLM +├── generate_context.py # Génère un contexte pour les LLM +└── .env.example # Exemple de fichier d'environnement +``` + +--- + +## 1. Fichier .env.example +```ini +# Fichier : .env +# À placer dans /volume1/homes/Master/App/Automation/ +# Ne jamais commiter ce fichier dans Git ! + +TRILIUM_TOKEN=ton_token_etapi_ici +TRILIUM_API_URL=http://localhost:4292/etapi +ROOT_NOTE_ID=root +``` + +--- + +## 2. Script : init_trilium_architecture.py +```python +import requests +import json +import os +from dotenv import load_dotenv + +# Charger les variables d'environnement depuis .env +load_dotenv() + +# ===== CONFIGURATION ===== +TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "http://localhost:4292/etapi") +TRILIUM_TOKEN = os.getenv("TRILIUM_TOKEN") +HEADERS = { + "Authorization": TRILIUM_TOKEN, + "Content-Type": "application/json" +} + +# ID de la note racine (root) - à remplacer par le tien +ROOT_NOTE_ID = os.getenv("ROOT_NOTE_ID", "root") + +# ===== FONCTIONS ===== +def create_note(parent_id, title, note_type="book"): + """Crée une note de type 'book' (dossier) ou 'text' dans Trilium.""" + data = { + "parentNoteId": parent_id, + "title": title, + "type": note_type, + "content": f"# {title}\n\n*Dossier créé automatiquement via API ETAPI.*" + } + response = requests.post( + f"{TRILIUM_API_URL}/notes", + headers=HEADERS, + json=data + ) + if response.status_code == 200: + note_id = response.json().get("noteId") + print(f"✅ Dossier créé : {title} (ID: {note_id})") + return note_id + else: + raise Exception(f"❌ Erreur {response.status_code} : {response.text}") + +def get_note_id_by_title(title, parent_id): + """Récupère l'ID d'une note par son titre (pour éviter les doublons).""" + search_url = f"{TRILIUM_API_URL}/search?q=title:{title}" + response = requests.get(search_url, headers=HEADERS) + if response.status_code == 200: + notes = response.json() + for note in notes: + if note.get("title") == title and note.get("parentNoteId") == parent_id: + return note.get("noteId") + return None + +# ===== EXÉCUTION ===== +def main(): + print(" Initialisation de l'architecture Trilium pour 'Context Continuity'...") + + # 1. Créer le dossier principal "Context Continuity" (s'il n'existe pas) + context_continuity_id = get_note_id_by_title("Context Continuity", ROOT_NOTE_ID) + if not context_continuity_id: + context_continuity_id = create_note(ROOT_NOTE_ID, "Context Continuity", "book") + else: + print(f"✅ Dossier existant : Context Continuity (ID: {context_continuity_id})") + + # 2. Créer les sous-dossiers + subfolders = ["Conversations", "Backlog", "Décisions", "Glossaire", "Historique"] + folder_ids = {} + + for folder in subfolders: + folder_id = get_note_id_by_title(folder, context_continuity_id) + if not folder_id: + folder_id = create_note(context_continuity_id, folder, "book") + else: + print(f"✅ Dossier existant : {folder} (ID: {folder_id})") + folder_ids[folder] = folder_id + + # 3. Afficher les IDs pour référence + print("\n IDs des dossiers créés (à utiliser dans tes scripts) :") + for folder, folder_id in folder_ids.items(): + print(f"{folder}: {folder_id}") + + # 4. Sauvegarder les IDs dans un fichier JSON + with open("trilium_folder_ids.json", "w") as f: + json.dump({ + "root_id": ROOT_NOTE_ID, + "context_continuity_id": context_continuity_id, + **folder_ids + }, f, indent=4) + print("\n IDs sauvegardés dans trilium_folder_ids.json") + +if __name__ == "__main__": + main() +``` + +--- + +## 3. Script : trilium_api.py +```python +import requests +import os +from dotenv import load_dotenv + +# Charger les variables d'environnement +load_dotenv() + +TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "http://localhost:4292/etapi") +TRILIUM_TOKEN = os.getenv("TRILIUM_TOKEN") +HEADERS = { + "Authorization": TRILIUM_TOKEN, + "Content-Type": "application/json" +} + +def create_note(parent_id, title, content, note_type="text"): + """Crée une note dans Trilium.""" + data = { + "parentNoteId": parent_id, + "title": title, + "type": note_type, + "content": content + } + response = requests.post( + f"{TRILIUM_API_URL}/notes", + headers=HEADERS, + json=data + ) + if response.status_code == 200: + return response.json().get("noteId") + else: + raise Exception(f"Erreur {response.status_code} : {response.text}") + +def get_note(note_id): + """Récupère une note par son ID.""" + response = requests.get( + f"{TRILIUM_API_URL}/notes/{note_id}", + headers=HEADERS + ) + return response.json() + +def update_note(note_id, content): + """Met à jour une note existante.""" + data = {"content": content} + response = requests.put( + f"{TRILIUM_API_URL}/notes/{note_id}", + headers=HEADERS, + json=data + ) + return response.status_code == 200 + +def search_notes(query): + """Recherche des notes par titre ou contenu.""" + response = requests.get( + f"{TRILIUM_API_URL}/search?q={query}", + headers=HEADERS + ) + return response.json() +``` + +--- + +## 4. Script : create_conversation.py +```python +from trilium_api import create_note +from datetime import datetime +import os +from dotenv import load_dotenv + +load_dotenv() + +# IDs des dossiers (à remplacer par les tiens) +CONVERSATIONS_ID = os.getenv("CONVERSATIONS_ID", "ID_DU_DOSSIER_CONVERSATIONS") + +def create_conversation(llm, title, content): + """Crée une note de conversation dans Trilium.""" + date = datetime.now().strftime("%Y-%m-%d %H:%M") + note_title = f"[{llm}] - {date} - {title}" + note_content = f"""# {note_title} + +**Projet** : [[Context Continuity]] +**LLM** : {llm} +**Date** : {date} +**Statut** : En cours + +--- +## Contexte +{content} + +--- +**Liens** : +- [[Backlog/]] +- [[Décisions/]] +""" + note_id = create_note( + parent_id=CONVERSATIONS_ID, + title=note_title, + content=note_content + ) + print(f"Note créée : {note_id} ({note_title})") + return note_id + +# Exemple d'utilisation +if __name__ == "__main__": + create_conversation( + llm="Le Chat", + title="Configuration Trilium pour Context Continuity", + content="Discussion sur la configuration de Trilium pour synchroniser les contextes entre LLM." + ) +``` + +--- + +## 5. Script : generate_context.py +```python +from trilium_api import search_notes, get_note +from datetime import datetime +import os +from dotenv import load_dotenv + +load_dotenv() + +CONTEXT_CONTINUITY_ID = os.getenv("CONTEXT_CONTINUITY_ID", "ID_DU_DOSSIER_CONTEXT_CONTINUITY") + +def generate_context(project_name, limit=10): + """Génère un contexte pour un projet donné en agrégeant les notes Trilium.""" + query = f"projet:{project_name}" + notes = search_notes(query) + + context = f"# Contexte du projet : {project_name}\n\n" + context += "## Conversations récentes\n" + for note in notes[:limit]: + note_data = get_note(note["noteId"]) + context += f"- **{note_data['title']}** (LLM: {note_data.get('llm', 'N/A')}, Date: {note_data.get('date', 'N/A')})\n" + context += f" {note_data['content'][:200]}...\n\n" + + context += f"\n*Généré le {datetime.now().strftime('%Y-%m-%d %H:%M')}*" + return context + +# Exemple d'utilisation +if __name__ == "__main__": + context = generate_context("Context Continuity") + print(context) +``` + +--- + +## Instructions d'Utilisation + +### 1. Préparer l'environnement +```bash +# Installer les dépendances +pip install requests python-dotenv + +# Créer le fichier .env +cd /volume1/homes/Master/App/Automation/ +echo "TRILIUM_TOKEN=ton_token_etapi" > .env +echo "TRILIUM_API_URL=http://localhost:4292/etapi" >> .env +echo "ROOT_NOTE_ID=root" >> .env +``` + +### 2. Exécuter les scripts +```bash +# Initialiser l'arborescence Trilium +python3 init_trilium_architecture.py + +# Créer une conversation +python3 create_conversation.py + +# Générer un contexte +python3 generate_context.py > contexte.md +``` + +### 3. Vérifier les résultats +- Ouvre Trilium et vérifie que les dossiers et notes sont créés. +- Le fichier `trilium_folder_ids.json` contient les IDs des dossiers. + +--- + +## Notes Importantes +- **Ne jamais partager le fichier .env** (il contient ton token ETAPI). +- Les scripts supposent que Trilium est accessible via `http://localhost:4292/etapi`. +- Si tu utilises le Reverse Proxy, remplace `TRILIUM_API_URL` par `https://trilium.bertha-cloud.fr/etapi`. \ No newline at end of file diff --git a/trilium_context.py b/trilium_context.py new file mode 100644 index 0000000..a2ae8da --- /dev/null +++ b/trilium_context.py @@ -0,0 +1,365 @@ +""" +trilium_context.py — Gestion opérationnelle du contexte LLM via Trilium +Usage : + python trilium_context.py new-conversation --projet SlidingAutomation --llm "Claude Sonnet" --titre "Session render engine" + python trilium_context.py close-session --note-id --summary "..." + python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Le Chat Large" [--note-id ] + python trilium_context.py list-backlog --projet SlidingAutomation + python trilium_context.py add-decision --projet SlidingAutomation --enonce "..." --justification "..." + python trilium_context.py add-history --projet SlidingAutomation --type "Test effectué" --enonce "..." --detail "..." + python trilium_context.py list-projects + python trilium_context.py add-skill --titre "Mon skill" --fichier skill.md --portee universel + python trilium_context.py add-skill --titre "Mon skill" --fichier skill.md --portee projet --projet SlidingAutomation + python trilium_context.py list-skills [--projet SlidingAutomation] [--portee universel] +""" +import argparse +import json +import os +import sys +from datetime import datetime +from trilium_api import (check_api, create_note, get_note_id, get_note, get_note_content, + update_note_content, search_by_label, set_label, + get_label_value, find_note_by_title) + +IDS_FILE = os.path.join(os.path.dirname(__file__), "trilium_ids.json") + +GREEN, YELLOW, RED, BOLD, RESET = "\033[32m", "\033[33m", "\033[31m", "\033[1m", "\033[0m" +def ok(m): print(f" {GREEN}✓{RESET} {m}") +def warn(m): print(f" {YELLOW}⚠{RESET} {m}") +def err(m): print(f" {RED}✗{RESET} {m}") +def head(m): print(f"\n{BOLD}{m}{RESET}") + +def load_ids() -> dict: + if not os.path.exists(IDS_FILE): + raise SystemExit("❌ trilium_ids.json introuvable. Lance d'abord : python trilium_init.py") + with open(IDS_FILE) as f: + return json.load(f) + +def estimate_tokens(text: str) -> int: + return max(1, int(len(text) / 4)) + +# --------------------------------------------------------------------------- +# Commandes +# --------------------------------------------------------------------------- + +def cmd_new_conversation(args): + ids = load_ids() + date = datetime.now().strftime("%Y-%m-%d %H:%M") + titre = f"[{args.llm}] {date} — {args.titre}" + content = f"""

{titre}

+ + + + + +
Projet{args.projet}
LLM{args.llm}
Date{date}
Synthèse de clôtureÀ remplir en fin de session
""" + result = create_note(ids["Conversations"], titre, content) + note_id = result["note"]["noteId"] + set_label(note_id, "type", "conversation") + set_label(note_id, "projet", args.projet) + set_label(note_id, "llm", args.llm) + set_label(note_id, "statut", "en-cours") + set_label(note_id, "date", date) + ok(f"Conversation créée : {titre}") + print(f"\n Note l'ID pour close-session : {BOLD}{note_id}{RESET}\n") + return note_id + +def cmd_close_session(args): + date = datetime.now().strftime("%Y-%m-%d %H:%M") + content = get_note_content(args.note_id) + # Remplace le placeholder de synthèse + if "À remplir en fin de session" in content: + content = content.replace( + "À remplir en fin de session", + args.summary.replace("<", "<").replace(">", ">") + ) + else: + content += f"\n

Synthèse de clôture ({date})

{args.summary}

" + update_note_content(args.note_id, content) + set_label(args.note_id, "statut", "clos") + set_label(args.note_id, "syntheseCloture", args.summary[:200]) + ok(f"Session clôturée (ID: {args.note_id})") + +def cmd_generate_context(args): + ids = load_ids() + date = datetime.now().strftime("%d/%m/%Y") + + # Récupérer les données du projet + projets = search_by_label("projet", args.projet) + decisions = search_by_label("type", "decision") + decisions = [d for d in decisions if get_label_value(d["noteId"], "projet") == args.projet + and get_label_value(d["noteId"], "statut") == "active"] + historique = search_by_label("type", "historiqueItem") + historique = [h for h in historique if get_label_value(h["noteId"], "projet") == args.projet + and get_label_value(h["noteId"], "encoreValide") == "true"] + backlog = search_by_label("type", "backlogItem") + backlog = [b for b in backlog if get_label_value(b["noteId"], "projet") == args.projet + and get_label_value(b["noteId"], "statut") not in ("fait", "abandonné")] + glossaire = search_by_label("type", "termeGlossaire") + glossaire = [g for g in glossaire if get_label_value(g["noteId"], "projet") == args.projet] + + # Synthèse de la session précédente + last_summary = "" + if args.note_id: + last_summary = get_label_value(args.note_id, "syntheseCloture") or "" + + # Construire le briefing + lines = [ + f"# REPRISE DE CONTEXTE — {args.projet} — v{args.version} — {date}", + f"**LLM cible : {args.llm_cible}**", "", + ] + + if projets: + p = projets[0] + content = get_note_content(p["noteId"]) + # Extraction texte brut simplifié + import re + texte = re.sub(r"<[^>]+>", " ", content).strip() + lines += ["## 1. Projet", texte[:400], ""] + + if last_summary: + lines += ["## 2. Où on en était", last_summary, ""] + + if decisions: + lines += ["## 3. Décisions actives (ne pas remettre en question)"] + for d in decisions: + lines.append(f"- {d.get('title', '?')}") + lines.append("") + + if historique: + lines += ["## 4. Historique — déjà testé / établi (ne pas refaire)"] + for h in historique: + type_h = get_label_value(h["noteId"], "typeHistorique") or "" + lines.append(f"- [{type_h}] {h.get('title', '?')}") + lines.append("") + + if glossaire: + lines += ["## 5. Glossaire projet"] + for g in glossaire: + lines.append(f"- **{g.get('title','?')}** : {get_label_value(g['noteId'], 'definition') or '(voir note)'}") + lines.append("") + + if backlog: + lines += ["## 6. Backlog actif"] + prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2} + backlog_sorted = sorted(backlog, + key=lambda b: prio_ordre.get(get_label_value(b["noteId"], "priorite") or "basse", 99)) + for b in backlog_sorted: + prio = get_label_value(b["noteId"], "priorite") or "?" + statut = get_label_value(b["noteId"], "statut") or "?" + lines.append(f"- [{statut}] [{prio}] {b.get('title','?')}") + lines.append("") + + lines += [ + "---", + "**Confirme la prise en compte du contexte en 3 lignes :**", + "(a) L'objectif du projet selon ta compréhension", + "(b) La prochaine action concrète", + "(c) Une incertitude ou question que tu identifies", + ] + + briefing = "\n".join(lines) + tokens = estimate_tokens(briefing) + titre = f"Reprise {args.projet} v{args.version} — {date}" + + # Créer la note Contexte Reprise dans Trilium + ids = load_ids() + result = create_note(ids["ContextesReprise"], titre, briefing.replace("\n", "
")) + ctx_id = result["note"]["noteId"] + set_label(ctx_id, "type", "contexteReprise") + set_label(ctx_id, "projet", args.projet) + set_label(ctx_id, "llmCible", args.llm_cible) + set_label(ctx_id, "version", str(args.version)) + set_label(ctx_id, "tokens", str(tokens)) + if args.note_id: + set_label(ctx_id, "conversationSource", args.note_id) + + head("CONTEXTE DE REPRISE GÉNÉRÉ") + ok(f"Note Trilium : {titre} (ID: {ctx_id})") + ok(f"Tokens estimés : ~{tokens:,}") + print("\n" + "═"*60) + print("\n BRIEFING À COPIER-COLLER :\n") + print(briefing) + print("\n" + "═"*60) + +def cmd_list_backlog(args): + backlog = search_by_label("type", "backlogItem") + items = [b for b in backlog + if get_label_value(b["noteId"], "projet") == args.projet + and get_label_value(b["noteId"], "statut") not in ("fait", "abandonné")] + if not items: + ok("Backlog vide (ou tout est fait).") + return + head(f"BACKLOG — {args.projet} ({len(items)} items actifs)") + emoji = {"haute": "", "moyenne": "", "basse": ""} + prio_ordre = {"haute": 0, "moyenne": 1, "basse": 2} + for b in sorted(items, key=lambda x: prio_ordre.get( + get_label_value(x["noteId"], "priorite") or "basse", 99)): + prio = get_label_value(b["noteId"], "priorite") or "?" + statut = get_label_value(b["noteId"], "statut") or "?" + e = emoji.get(prio, "⚪") + print(f" {e} [{statut}] {b.get('title','?')} [id: {b['noteId']}]") + +def cmd_add_decision(args): + ids = load_ids() + date = datetime.now().strftime("%Y-%m-%d") + result = create_note( + ids["Decisions"], args.enonce, + f"

Justification : {args.justification}

Date : {date}

" + ) + nid = result["note"]["noteId"] + set_label(nid, "type", "decision") + set_label(nid, "projet", args.projet) + set_label(nid, "statut", "active") + set_label(nid, "llm", args.llm or "") + ok(f"Décision créée : {args.enonce[:60]} (ID: {nid})") + +def cmd_add_history(args): + ids = load_ids() + date = datetime.now().strftime("%Y-%m-%d") + result = create_note( + ids["Historique"], args.enonce, + f"

Type : {args.type_h}

" + f"

Détail : {args.detail or '—'}

" + f"

Date : {date}

" + ) + nid = result["note"]["noteId"] + set_label(nid, "type", "historiqueItem") + set_label(nid, "projet", args.projet) + set_label(nid, "typeHistorique", args.type_h) + set_label(nid, "encoreValide", "true") + ok(f"Historique créé : {args.enonce[:60]} (ID: {nid})") + + +def cmd_add_backlog(args): + ids = load_ids() + result = create_note(ids["Backlog"], args.titre) + nid = get_note_id(result) + set_label(nid, "type", "backlogItem") + set_label(nid, "projet", args.projet) + set_label(nid, "priorite", args.priorite) + set_label(nid, "statut", "a faire") + ok(f"Backlog item cree : {args.titre[:60]} (ID: {nid})") + +def cmd_list_projects(args): + projets = search_by_label("type", "projet") + head(f"PROJETS ({len(projets)} trouvés)") + for p in projets: + statut = get_label_value(p["noteId"], "statut") or "?" + print(f" [{statut}] {p.get('title','?')} [id: {p['noteId']}]") + +def cmd_add_skill(args): + if args.portee != "universel" and not args.projet: + err("--projet requis sauf si --portee universel") + sys.exit(1) + ids = load_ids() + existant = find_note_by_title(args.titre, ids["Skills"]) + if existant: + err(f"Un skill '{args.titre}' existe déjà (ID: {existant})") + sys.exit(1) + with open(args.fichier, encoding="utf-8") as f: + contenu = f.read() + safe = contenu.replace("<", "<").replace(">", ">") + result = create_note(ids["Skills"], args.titre, f"
{safe}
") + nid = get_note_id(result) + set_label(nid, "type", "skill") + set_label(nid, "portee", args.portee) + if args.projet: + set_label(nid, "projet", args.projet) + ok(f"Skill créé : {args.titre} (ID: {nid})") + +def cmd_list_skills(args): + skills = search_by_label("type", "skill") + if args.projet: + skills = [s for s in skills + if get_label_value(s["noteId"], "projet") == args.projet + or get_label_value(s["noteId"], "portee") == "universel"] + if args.portee: + skills = [s for s in skills if get_label_value(s["noteId"], "portee") == args.portee] + if not skills: + ok("Aucun skill trouvé pour ces filtres.") + return + head(f"SKILLS ({len(skills)} trouvés)") + for s in skills: + portee = get_label_value(s["noteId"], "portee") or "?" + projet = get_label_value(s["noteId"], "projet") or "—" + print(f" [{portee}] [{projet}] {s.get('title','?')} [id: {s['noteId']}]") + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Gestion du contexte LLM multi-modèles via Trilium", + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + + p1 = sub.add_parser("new-conversation") + p1.add_argument("--projet", required=True) + p1.add_argument("--llm", required=True) + p1.add_argument("--titre", required=True) + + p2 = sub.add_parser("close-session") + p2.add_argument("--note-id", required=True) + p2.add_argument("--summary", required=True) + + p3 = sub.add_parser("generate-context") + p3.add_argument("--projet", required=True) + p3.add_argument("--llm-cible", required=True) + p3.add_argument("--note-id", default="") + p3.add_argument("--version", type=int, default=1) + + p4 = sub.add_parser("list-backlog") + p4.add_argument("--projet", required=True) + + p5 = sub.add_parser("add-decision") + p5.add_argument("--projet", required=True) + p5.add_argument("--enonce", required=True) + p5.add_argument("--justification", default="") + p5.add_argument("--llm", default="") + + p6 = sub.add_parser("add-history") + p6.add_argument("--projet", required=True) + p6.add_argument("--type", required=True, dest="type_h", + choices=["Fait etabli", "Test effectue", "Hypothese invalidee", "Contrainte decouverte"]) + p6.add_argument("--enonce", required=True) + p6.add_argument("--detail", default="") + + + p_ab = sub.add_parser("add-backlog", help="Ajoute un item au backlog") + p_ab.add_argument("--projet", required=True) + p_ab.add_argument("--titre", required=True) + p_ab.add_argument("--priorite", default="moyenne", + choices=["haute", "moyenne", "basse"]) + sub.add_parser("list-projects") + + p_as = sub.add_parser("add-skill", help="Enregistre un skill depuis un fichier") + p_as.add_argument("--titre", required=True) + p_as.add_argument("--fichier", required=True, help="Chemin du fichier .md contenant le skill") + p_as.add_argument("--portee", default="projet", + help="universel | reference-technique | projet | ... (defaut: projet)") + p_as.add_argument("--projet", default="", help="Requis sauf si --portee universel") + + p_ls = sub.add_parser("list-skills", help="Liste les skills enregistres") + p_ls.add_argument("--projet", default="") + p_ls.add_argument("--portee", default="") + + args = parser.parse_args() + check_api() + + { + "new-conversation": cmd_new_conversation, + "close-session": cmd_close_session, + "generate-context": cmd_generate_context, + "list-backlog": cmd_list_backlog, + "add-decision": cmd_add_decision, + "add-history": cmd_add_history, + "add-backlog": cmd_add_backlog, + "list-projects": cmd_list_projects, + "add-skill": cmd_add_skill, + "list-skills": cmd_list_skills, + }[args.command](args) + +if __name__ == "__main__": + main() diff --git a/trilium_ids.json b/trilium_ids.json new file mode 100644 index 0000000..d4c6f1c --- /dev/null +++ b/trilium_ids.json @@ -0,0 +1,15 @@ +{ + "root": "Vci57mSRj4tN", + "Projets": "i5GuPlZmDDj7", + "Conversations": "rO1VOLxlZStD", + "Backlog": "tSE6NSAxThpE", + "Decisions": "JcraxD7XwhWa", + "Historique": "S4NBIOBcmnY8", + "Glossaire": "nDj8Z4zEkZXZ", + "ContextesReprise": "XN2uR5Y7ZEYF", + "Skills": "ol1h09MhksPw", + "ModelesOntologie": "z5a9JmEyiAlF", + "ProcessWorkflows": "Cw3CkUKi2W8N", + "EnvironnementTechnique": "aUZ4qIUVEbcz", + "Methodes": "ZRVaYgdw19t2" +} \ No newline at end of file diff --git a/trilium_init.py b/trilium_init.py new file mode 100644 index 0000000..525f53f --- /dev/null +++ b/trilium_init.py @@ -0,0 +1,163 @@ +""" +trilium_init.py — Initialisation idempotente de l'arborescence Trilium +Usage : + python trilium_init.py + python trilium_init.py --with-example + python trilium_init.py --show-ids +""" +import argparse +import json +import os +from trilium_api import (check_api, create_note, get_note_id, + find_note_by_title, set_label, update_note_content) + +GREEN, YELLOW, RESET, BOLD = "\033[32m", "\033[33m", "\033[0m", "\033[1m" +def ok(m): print(f" {GREEN}✓{RESET} {m}") +def warn(m): print(f" {YELLOW}⚠{RESET} {m}") +def head(m): print(f"\n{BOLD}{m}{RESET}") + +IDS_FILE = os.path.join(os.path.dirname(__file__), "trilium_ids.json") + +def get_or_create(parent_id, title, note_type="text", labels=None): + existing = find_note_by_title(title, parent_id) + if existing: + warn(f"Existant : {title} (ID: {existing})") + return existing + result = create_note(parent_id, title, note_type=note_type) + note_id = get_note_id(result) + if labels: + for k, v in labels.items(): + set_label(note_id, k, v) + ok(f"Créé : {title} (ID: {note_id})") + return note_id + +def init_structure(): + head("Initialisation de l'arborescence Trilium") + ids = {} + + root_id = get_or_create("root", "Context Continuity", "book", + {"type": "projet"}) + ids["root"] = root_id + + children_map = { + "Projets": "Projets", + "Conversations": "Conversations", + "Backlog": "Backlog", + "Décisions": "Decisions", + "Historique": "Historique", + "Glossaire": "Glossaire", + "Contextes Reprise": "ContextesReprise", + } + for title, key in children_map.items(): + ids[key] = get_or_create(root_id, title, "book", + {"type": "container"}) + + with open(IDS_FILE, "w") as f: + json.dump(ids, f, indent=2, ensure_ascii=False) + ok(f"IDs sauvegardés dans {IDS_FILE}") + return ids + +def create_example_project(ids): + head("Création du projet exemple : Sliding Automation") + projet_id = get_or_create( + ids["Projets"], "Sliding Automation", "text", + {"type": "projet", "projet": "SlidingAutomation", "statut": "actif"} + ) + update_note_content(projet_id, + "

Sliding Automation

" + "

Objectif : Pipeline de génération automatique de " + "présentations PPTX via agents Mistral.

" + "

Stack : Python, python-pptx, Mistral Large/Small, YAML

" + "

LLM : Claude → architecture & rédaction longue | " + "Le Chat → génération code & itérations

" + ) + + head("Backlog Items") + backlog_items = [ + ("Déployer patch_render_engine.py (chantiers A/B/C/D)", "haute", "à faire"), + ("Analyser feedbacks slides 5-17", "haute", "à faire"), + ("Fix two_cols_text : tirets Markdown visibles", "moyenne", "à faire"), + ("Fix executive_summary : blocs SCR mal positionnés", "moyenne", "à faire"), + ("Initialiser versioning Git (Forgejo ou Codeberg)", "basse", "à faire"), + ] + for titre, prio, statut in backlog_items: + get_or_create(ids["Backlog"], titre, "text", + {"type": "backlogItem", "projet": "SlidingAutomation", + "priorite": prio, "statut": statut}) + + head("Historique") + historique = [ + ("Fix semantic key : self.theme['semantic']", + "Fait établi", + "Clé corrigée dans render_engine.py — ne pas revenir en arrière."), + ("Fix polices : détection via assets/fonts/ (pas fc-list)", + "Fait établi", + "fc-list indisponible sur DS218 ARM64. Détection locale obligatoire."), + ("Fix Python 3.9 : Union[X|Y] non supporté", + "Contrainte découverte", + "DS218 tourne Python 3.9. Utiliser Union[X, Y] / Optional[X]."), + ] + for titre, type_h, detail in historique: + nid = get_or_create(ids["Historique"], titre, "text", + {"type": "historiqueItem", + "projet": "SlidingAutomation", + "typeHistorique": type_h, + "encoreValide": "true"}) + update_note_content(nid, f"

{detail}

") + + head("Glossaire") + glossaire = [ + ("render_engine.py", + "Script principal de rendu PPTX. " + "NE PAS confondre avec facilitator.py."), + ("Chantier A/B/C/D", + "4 ensembles de corrections visuelles regroupées " + "dans patch_render_engine.py."), + ("Layout", + "Template de slide défini dans layouts.yaml. " + "28 layouts disponibles (L01-L28)."), + ] + for terme, definition in glossaire: + nid = get_or_create(ids["Glossaire"], terme, "text", + {"type": "termeGlossaire", + "projet": "SlidingAutomation"}) + update_note_content(nid, f"

Définition : {definition}

") + + ok("Projet Sliding Automation initialisé.") + +def show_ids(): + if not os.path.exists(IDS_FILE): + print("❌ trilium_ids.json introuvable. " + "Lance d'abord : python trilium_init.py") + return + with open(IDS_FILE) as f: + ids = json.load(f) + head("IDs Trilium") + for k, v in ids.items(): + print(f" {k:<20} : {v}") + +def main(): + parser = argparse.ArgumentParser( + description="Initialise l'arborescence Trilium") + parser.add_argument("--with-example", action="store_true", + help="Crée aussi les données Sliding Automation") + parser.add_argument("--show-ids", action="store_true", + help="Affiche les IDs existants") + args = parser.parse_args() + + if args.show_ids: + show_ids() + return + + check_api() + ids = init_structure() + + if args.with_example: + create_example_project(ids) + + head("Done ✓") + print(" Lance : python trilium_context.py list-backlog " + "--projet SlidingAutomation") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/trilium_logger.py b/trilium_logger.py new file mode 100644 index 0000000..21fd7d8 --- /dev/null +++ b/trilium_logger.py @@ -0,0 +1,64 @@ +""" +trilium_logger.py — Intégration pipeline Sliding Automation +À appeler depuis facilitator.py pour logger chaque génération automatiquement. + +Usage depuis facilitator.py : + from trilium_logger import log_session + log_session("Mon titre", nb_slides=12, yaml_path="output/x.yaml", + pptx_path="output/x.pptx", layouts=["cover_split", "big_stat"]) +""" +import os +from datetime import datetime +from dotenv import load_dotenv + +load_dotenv() + +# Import relatif — suppose que trilium_logger.py est dans le même dossier +# que trilium_api.py, ou que le chemin est dans PYTHONPATH +import sys +sys.path.insert(0, os.path.dirname(__file__)) +from trilium_api import create_note, set_label, find_note_by_title + +TRILIUM_URL = os.getenv("TRILIUM_URL", "http://localhost:4292") +DEFAULT_PROJ = os.getenv("DEFAULT_PROJECT", "SlidingAutomation") + +# ID du dossier Sessions dans Sliding Pipeline (à renseigner après init) +# Récupérable via : python trilium_init.py --show-ids +SESSIONS_PARENT_ID = os.getenv("TRILIUM_SESSIONS_ID", "") + +def log_session(titre: str, nb_slides: int, yaml_path: str, pptx_path: str, + layouts: list = None, projet: str = None): + """ + Crée une note de session dans Trilium. + Appelé automatiquement par facilitator.py en fin de génération. + """ + if not SESSIONS_PARENT_ID: + print(" ⚠ TRILIUM_SESSIONS_ID non défini dans .env — log ignoré") + return + + projet = projet or DEFAULT_PROJ + date = datetime.now().strftime("%Y-%m-%d %H:%M") + layouts_str = ", ".join(layouts) if layouts else "non renseignés" + + content = f"""

Session {date}

+ + + + + + + +
Titre{titre}
Slides{nb_slides}
YAML{yaml_path}
PPTX{pptx_path}
Layouts{layouts_str}
Date{date}
""" + + try: + result = create_note(SESSIONS_PARENT_ID, f"Session {date} — {titre}", content) + note_id = result["note"]["noteId"] + set_label(note_id, "type", "sessionSliding") + set_label(note_id, "projet", projet) + set_label(note_id, "nbSlides", str(nb_slides)) + set_label(note_id, "yamlPath", yaml_path) + set_label(note_id, "pptxPath", pptx_path) + set_label(note_id, "layouts", layouts_str) + print(f" ✓ Session loggée dans Trilium : {titre} (ID: {note_id})") + except Exception as e: + print(f" ⚠ Trilium log failed : {e}") \ No newline at end of file diff --git a/watchdog.sh b/watchdog.sh new file mode 100644 index 0000000..7cdb09d --- /dev/null +++ b/watchdog.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Watchdog Context Continuity — verifie API (8765) et MCP (8766), +# relance le service via son start_*.sh s'il ne repond pas sur /health. +# Idempotent : couvre le boot (rien ne tourne) ET le crash. +# Lance par tache planifiee DSM toutes les 5 min. + +BASE=/volume1/homes/Master/App/Context_continuity +LOG="$BASE/watchdog.log" +TS=$(date '+%Y-%m-%d %H:%M:%S') + +# Verifie /health avec plusieurs essais. Retourne 0 si OK, 1 sinon. +wait_health() { + URL="$1" + TRIES="$2" + i=0 + while [ "$i" -lt "$TRIES" ]; do + if curl -s -f -m 5 "$URL" > /dev/null 2>&1; then + return 0 + fi + i=$((i + 1)) + sleep 3 + done + return 1 +} + +check_and_restart() { + NAME="$1" # libelle (api / mcp) + URL="$2" # url health + STARTER="$3" # script de demarrage + + # Premier check rapide (1 essai) : service deja vivant ? + if curl -s -f -m 5 "$URL" > /dev/null 2>&1; then + return 0 + fi + + # Service muet : on relance + echo "$TS [$NAME] KO sur $URL — relance via $STARTER" >> "$LOG" + sh "$BASE/$STARTER" + + # Verification avec patience (jusqu'a 5 essais = ~15s, le demarrage uvicorn peut etre lent) + if wait_health "$URL" 5; then + echo "$TS [$NAME] relance OK" >> "$LOG" + else + echo "$TS [$NAME] ECHEC relance — toujours muet apres ~15s" >> "$LOG" + fi +} + +check_and_restart "api" "http://127.0.0.1:8765/api/health" "start_api.sh" +check_and_restart "mcp" "http://127.0.0.1:8766/health" "start_mcp.sh"