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.
This commit is contained in:
2026-06-29 11:02:22 +02:00
commit 01629780f4
26 changed files with 5259 additions and 0 deletions
+26
View File
@@ -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
+60
View File
@@ -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=<NomDuProjet>`. 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.
+677
View File
@@ -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"<br\s*/?>", "\n", text)
text = re.sub(r"<[^>]+>", "", text)
return text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").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"<h2>{body.nom}</h2><p><b>Objectif</b> : {body.objectif}</p>"
if body.stack:
content += f"<p><b>Stack</b> : {body.stack}</p>"
if body.llm_repartition:
content += f"<p><b>LLM</b> : {body.llm_repartition}</p>"
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"<p><b>Objectif mis à jour</b> : {body.objectif}</p>"
if body.stack:
content += f"<p><b>Stack</b> : {body.stack}</p>"
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"<p><b>Justification</b> : {body.justification or '-'}</p>")
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"<p><b>Révision ({llm})</b> : {body.justification}</p>"
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"<p><b>Type</b> : {type_h}</p>"
f"<p><b>Détail</b> : {body.detail or '-'}</p>")
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"<p><b>Mise à jour ({llm})</b> : {body.detail}</p>"
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"<h2>{titre}</h2>"
f"<table>"
f"<tr><td><b>Projet</b></td><td>{body.projet}</td></tr>"
f"<tr><td><b>LLM</b></td><td>{body.llm}</td></tr>"
f"<tr><td><b>Date</b></td><td>{date}</td></tr>"
f"<tr><td><b>Synthèse de clôture</b></td>"
f"<td><i>À remplir en fin de session</i></td></tr>"
f"</table>")
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(
"<i>À remplir en fin de session</i>",
body.synthese_cloture.replace("<", "&lt;").replace(">", "&gt;")
)
if "<i>À remplir en fin de session</i>" not in get_note_content(note_id):
content += f"<h3>Synthèse</h3><p>{body.synthese_cloture}</p>"
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"<p><b>Définition</b> : {body.definition}</p>"
if body.synonymes_eviter:
content += f"<p><b>Synonymes à éviter</b> : {body.synonymes_eviter}</p>"
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"<p><b>Définition mise à jour ({llm})</b> : {body.definition}</p>"
update_note_content(note_id, content)
if body.synonymes_eviter:
content = get_note_content(note_id)
content += f"<p><b>Synonymes à éviter</b> : {body.synonymes_eviter}</p>"
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"<br\s*/?>", "\n", content)
texte = re.sub(r"<[^>]+>", "", texte)
texte = texte.replace("&amp;", "&").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"<pre>{body.contenu}</pre>"
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"<pre>{body.contenu}</pre>")
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}
+30
View File
@@ -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 :")
+48
View File
@@ -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.")
+245
View File
@@ -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,
'<h1>API Context Continuity</h1>'
'<p><b>Base URL externe</b> : <code>https://api-trilium.bertha-cloud.fr</code><br>'
'<b>Base URL locale</b> : <code>http://localhost:8765</code><br>'
'<b>Doc Swagger</b> : <a href="https://api-trilium.bertha-cloud.fr/api/docs">https://api-trilium.bertha-cloud.fr/api/docs</a><br>'
'<b>Auth</b> : Header <code>Authorization: &lt;API_KEY&gt;</code></p>'
'<h2>Ressources disponibles</h2>'
'<table>'
'<tr><th>Ressource</th><th>Endpoints</th><th>Note Trilium</th></tr>'
'<tr><td>Projets</td><td>GET /api/projets, GET /api/projets/{nom}, POST, PATCH</td><td>Documentation &gt; Projets</td></tr>'
'<tr><td>Backlog</td><td>GET /api/backlog/{projet}, POST /api/backlog, PATCH</td><td>Documentation &gt; Backlog</td></tr>'
'<tr><td>Decisions</td><td>GET /api/decisions/{projet}, POST, PATCH</td><td>Documentation &gt; Decisions</td></tr>'
'<tr><td>Historique</td><td>GET /api/historique/{projet}, POST, PATCH</td><td>Documentation &gt; Historique</td></tr>'
'<tr><td>Conversations</td><td>GET /api/conversations/{projet}, POST, PATCH</td><td>Documentation &gt; Conversations</td></tr>'
'<tr><td>Glossaire</td><td>GET /api/glossaire/{projet}, POST, PATCH</td><td>Documentation &gt; Glossaire</td></tr>'
'<tr><td>Contexte Reprise</td><td>GET /api/contexte/{projet}, GET /api/contexte/{projet}/last</td><td>Documentation &gt; Contexte Reprise</td></tr>'
'</table>'
'<h2>Codes de retour</h2>'
'<table>'
'<tr><th>Code</th><th>Signification</th></tr>'
'<tr><td>200</td><td>OK</td></tr>'
'<tr><td>201</td><td>Cree avec succes</td></tr>'
'<tr><td>401</td><td>Cle API invalide</td></tr>'
'<tr><td>404</td><td>Ressource introuvable</td></tr>'
'<tr><td>422</td><td>Validation Pydantic echouee (parametre invalide)</td></tr>'
'</table>'
)
print('Index documentation cree:', nid)
# ── Notes par ressource ────────────────────────────────────────────────────
ressources = [
(
'API - Projets',
'<h1>API - Projets</h1>'
'<h2>GET /api/projets</h2>'
'<p>Liste tous les projets.</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/projets</pre>'
'<h2>GET /api/projets/{nom}</h2>'
'<p>Detail d un projet par son nom (valeur du label #projet).</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/projets/SlidingAutomation</pre>'
'<h2>POST /api/projets</h2>'
'<p>Cree un nouveau projet.</p>'
'<pre>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</pre>'
'<h2>PATCH /api/projets/{note_id}</h2>'
'<p>Modifie le statut ou l objectif. Champs optionnels : statut (actif|en-pause|archive), objectif, stack.</p>'
'<pre>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</pre>'
),
(
'API - Backlog',
'<h1>API - Backlog</h1>'
'<h2>GET /api/backlog/{projet}</h2>'
'<p>Liste les items actifs (hors fait et abandonne), tries par priorite.</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/backlog/SlidingAutomation</pre>'
'<h2>POST /api/backlog</h2>'
'<p>Cree un item. Champs : projet (requis), titre (requis), priorite (haute|moyenne|basse, defaut: moyenne).</p>'
'<pre>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</pre>'
'<h2>PATCH /api/backlog/{note_id}</h2>'
'<p>Modifie statut ou priorite. statut valide : a faire|en cours|bloque|fait|abandonne.</p>'
'<pre>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</pre>'
),
(
'API - Decisions',
'<h1>API - Decisions</h1>'
'<h2>GET /api/decisions/{projet}</h2>'
'<p>Liste les decisions actives (statut=active).</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/decisions/SlidingAutomation</pre>'
'<h2>POST /api/decisions</h2>'
'<p>Cree une decision. Champs : projet, enonce (requis), justification, llm.</p>'
'<pre>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</pre>'
'<h2>PATCH /api/decisions/{note_id}</h2>'
'<p>Modifie statut (active|revisee|annulee) ou justification.</p>'
'<pre>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</pre>'
),
(
'API - Historique',
'<h1>API - Historique</h1>'
'<h2>GET /api/historique/{projet}</h2>'
'<p>Liste les entrees encore valides (encoreValide=true).</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/historique/SlidingAutomation</pre>'
'<h2>POST /api/historique</h2>'
'<p>Cree une entree. type valide : Fait etabli|Test effectue|Hypothese invalidee|Contrainte decouverte.</p>'
'<pre>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</pre>'
'<h2>PATCH /api/historique/{note_id}</h2>'
'<p>Invalider une entree obsolete : encore_valide: false.</p>'
'<pre>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</pre>'
),
(
'API - Conversations',
'<h1>API - Conversations</h1>'
'<h2>GET /api/conversations/{projet}</h2>'
'<p>Liste toutes les conversations du projet.</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/conversations/SlidingAutomation</pre>'
'<h2>GET /api/conversations/{projet}/{note_id}</h2>'
'<p>Detail avec contenu HTML d une conversation specifique.</p>'
'<h2>POST /api/conversations</h2>'
'<p>Cree une conversation. llm valide : Claude Sonnet|Claude Opus|Le Chat Large|Le Chat Medium.</p>'
'<pre>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</pre>'
'<h2>PATCH /api/conversations/{note_id}</h2>'
'<p>Cloture une session : synthese_cloture (max 500 chars) et/ou statut (en-cours|clos).</p>'
'<pre>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</pre>'
),
(
'API - Glossaire',
'<h1>API - Glossaire</h1>'
'<h2>GET /api/glossaire/{projet}</h2>'
'<p>Liste les termes avec contenu (definition).</p>'
'<pre>curl -s -H "Authorization: CLE" https://api-trilium.bertha-cloud.fr/api/glossaire/SlidingAutomation</pre>'
'<h2>POST /api/glossaire</h2>'
'<p>Cree un terme. Champs : projet, terme, definition (requis), synonymes_eviter.</p>'
'<pre>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</pre>'
'<h2>PATCH /api/glossaire/{note_id}</h2>'
'<p>Modifie definition et/ou synonymes_eviter.</p>'
),
(
'API - Contexte Reprise',
'<h1>API - Contexte Reprise</h1>'
'<h2>GET /api/contexte/{projet}?llm_cible=NomLLM</h2>'
'<p>Genere et retourne le briefing de reprise en temps reel depuis Trilium.</p>'
'<pre>curl -s -H "Authorization: CLE" \\\n'
'"https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation?llm_cible=Le+Chat+Large"</pre>'
'<p>Retourne : {"projet":"...","tokens":N,"briefing":"# REPRISE DE CONTEXTE..."}</p>'
'<h2>GET /api/contexte/{projet}/last</h2>'
'<p>Retourne le dernier contexte de reprise sauvegarde dans Trilium.</p>'
'<pre>curl -s -H "Authorization: CLE" \\\n'
'https://api-trilium.bertha-cloud.fr/api/contexte/SlidingAutomation/last</pre>'
),
]
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,
'<h1>Skill - API FastAPI Context Continuity</h1>'
'<p>Ce skill permet a un LLM d appeler directement l API pour lire et ecrire dans Trilium.</p>'
'<h2>Configuration</h2>'
'<table>'
'<tr><th>Parametre</th><th>Valeur</th></tr>'
'<tr><td>Base URL</td><td>https://api-trilium.bertha-cloud.fr</td></tr>'
'<tr><td>Auth header</td><td>Authorization: &lt;API_KEY&gt;</td></tr>'
'<tr><td>Cle Claude</td><td>Demander a Bastien ou lire dans .env</td></tr>'
'<tr><td>Cle Le Chat</td><td>Demander a Bastien ou lire dans .env</td></tr>'
'<tr><td>Doc Swagger</td><td>https://api-trilium.bertha-cloud.fr/api/docs</td></tr>'
'</table>'
'<h2>Endpoints essentiels pour un LLM</h2>'
'<table>'
'<tr><th>Action</th><th>Appel</th></tr>'
'<tr><td>Lire le contexte projet</td><td>GET /api/contexte/{projet}?llm_cible=NomLLM</td></tr>'
'<tr><td>Voir le backlog</td><td>GET /api/backlog/{projet}</td></tr>'
'<tr><td>Enregistrer une decision</td><td>POST /api/decisions</td></tr>'
'<tr><td>Enregistrer un historique</td><td>POST /api/historique</td></tr>'
'<tr><td>Creer une conversation</td><td>POST /api/conversations</td></tr>'
'<tr><td>Cloturer une session</td><td>PATCH /api/conversations/{id}</td></tr>'
'<tr><td>Marquer backlog item fait</td><td>PATCH /api/backlog/{id}</td></tr>'
'</table>'
'<h2>Valeurs valides par champ</h2>'
'<table>'
'<tr><th>Champ</th><th>Valeurs acceptees</th></tr>'
'<tr><td>priorite</td><td>haute | moyenne | basse</td></tr>'
'<tr><td>statut backlog</td><td>a faire | en cours | bloque | fait | abandonne</td></tr>'
'<tr><td>statut decision</td><td>active | revisee | annulee</td></tr>'
'<tr><td>type historique</td><td>Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte</td></tr>'
'<tr><td>llm conversation</td><td>Claude Sonnet | Claude Opus | Le Chat Large | Le Chat Medium</td></tr>'
'<tr><td>statut projet</td><td>actif | en-pause | archive</td></tr>'
'</table>'
'<h2>Exemples curl complets</h2>'
'<h3>Lire le contexte de reprise</h3>'
'<pre>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\'])"</pre>'
'<h3>Enregistrer une decision</h3>'
'<pre>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</pre>'
'<h3>Marquer un item backlog comme fait</h3>'
'<pre>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</pre>'
'<h2>Recuperer ce skill</h2>'
'<pre>curl -s -H "Authorization: CLE" \\\n'
'"https://api-trilium.bertha-cloud.fr/api/contexte/ContextContinuity/last"</pre>'
)
print('Skill API FastAPI cree:', nid)
print('DONE')
+153
View File
@@ -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 = (
'<h1>README - Workflow Context Continuity</h1>'
'<p>Systeme de gestion du contexte multi-LLM (Claude + Le Chat) via Trilium comme base pivot.<br>'
'Permet de basculer entre les deux LLMs sans perdre le fil du projet.</p>'
'<h2>Prerequis</h2>'
'<pre>'
'cd ~/App/Context_continuity\n'
'source venv/bin/activate'
'</pre>'
'<h2>ETAPE 1 - Debut de session : creer la conversation</h2>'
'<p>Au debut de chaque session de travail avec un LLM, enregistre-la dans Trilium :</p>'
'<pre>python trilium_context.py new-conversation \\\n'
' --projet SlidingAutomation \\\n'
' --llm "Claude Sonnet" \\\n'
' --titre "Description courte de ce que tu vas faire"</pre>'
'<p><strong>Note l ID retourne</strong> — tu en auras besoin a la fin de la session.<br>'
'Exemple de retour : <code>Note l ID pour close-session : Igaa5BBIfNMw</code></p>'
'<h2>ETAPE 2 - Recuperer le contexte existant (optionnel)</h2>'
'<p>Si tu reprends un projet en cours, genere le briefing depuis la derniere session :</p>'
'<pre>python trilium_context.py generate-context \\\n'
' --projet SlidingAutomation \\\n'
' --llm-cible "Claude Sonnet" \\\n'
' --note-id ID_DERNIERE_CONV \\\n'
' --version N</pre>'
'<p>Le briefing est imprime dans le terminal.<br>'
'<strong>Copie-colle le texte imprime en debut de nouvelle session LLM.</strong><br>'
'Le LLM confirmera sa comprehension en 3 lignes (objectif / prochaine action / incertitude).</p>'
'<p>Pour trouver l ID de la derniere conversation :<br>'
'Dans Trilium : Context Continuity &gt; Conversations &gt; chercher la plus recente.<br>'
'Ou via terminal :</p>'
'<pre>python trilium_context.py list-projects</pre>'
'<h2>ETAPE 3 - Pendant la session : capitaliser au fil de l eau</h2>'
'<p>Ne pas attendre la fin — enregistre les informations importantes au fur et a mesure :</p>'
'<pre># 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"</pre>'
'<h2>ETAPE 4 - Limite de tokens approche : cloturer la session</h2>'
'<p>Signes que la limite approche : le LLM repete des choses deja dites, oublie une contrainte, '
'ses reponses deviennent moins precises.<br>'
'<strong>Action :</strong> demande au LLM actuel :</p>'
'<pre>Fais-moi une synthese de cloture en 200 mots :\n'
'ou on en est, ce qui reste a faire, les blocages eventuels.</pre>'
'<p>Puis enregistre cette synthese :</p>'
'<pre>python trilium_context.py close-session \\\n'
' --note-id ID_CONV_ACTUELLE \\\n'
' --summary "COLLE ICI LA SYNTHESE DU LLM"</pre>'
'<h2>ETAPE 5 - Generer le briefing de reprise</h2>'
'<pre>python trilium_context.py generate-context \\\n'
' --projet SlidingAutomation \\\n'
' --llm-cible "Le Chat Large" \\\n'
' --note-id ID_CONV_ACTUELLE \\\n'
' --version N</pre>'
'<p>Le terminal imprime le briefing complet.<br>'
'Une note "Contexte Reprise" est aussi creee automatiquement dans Trilium.</p>'
'<h2>ETAPE 6 - Basculer vers le second LLM</h2>'
'<ol>'
'<li>Ouvre une nouvelle session sur l autre LLM (Claude ou Le Chat)</li>'
'<li>Cree la nouvelle conversation dans Trilium :<br>'
'<pre>python trilium_context.py new-conversation \\\n'
' --projet SlidingAutomation \\\n'
' --llm "Le Chat Large" \\\n'
' --titre "Suite - description"</pre></li>'
'<li>Copie-colle le briefing imprime a l etape 5 en premier message de la nouvelle session</li>'
'<li>Le LLM confirme en 3 lignes — verifie qu il a bien compris avant de continuer</li>'
'<li>Reprends le travail depuis le backlog actif</li>'
'</ol>'
'<h2>ETAPE 7 - Voir l etat du projet a tout moment</h2>'
'<pre># Backlog actif\n'
'python trilium_context.py list-backlog --projet SlidingAutomation\n\n'
'# Liste des projets\n'
'python trilium_context.py list-projects</pre>'
'<h2>Resume visuel du cycle</h2>'
'<pre>'
'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'
'</pre>'
'<h2>Fichiers du systeme</h2>'
'<table>'
'<tr><th>Fichier</th><th>Role</th></tr>'
'<tr><td><code>trilium_api.py</code></td><td>Wrapper API Trilium (ne pas modifier)</td></tr>'
'<tr><td><code>trilium_init.py</code></td><td>Initialisation arborescence (une seule fois)</td></tr>'
'<tr><td><code>trilium_context.py</code></td><td>Script principal du workflow quotidien</td></tr>'
'<tr><td><code>trilium_logger.py</code></td><td>Integration pipeline Sliding Automation</td></tr>'
'<tr><td><code>trilium_ids.json</code></td><td>IDs des dossiers Trilium (genere par init)</td></tr>'
'<tr><td><code>.env</code></td><td>Token ETAPI et URL Trilium (ne pas commiter)</td></tr>'
'</table>'
'<h2>Skills disponibles dans Trilium</h2>'
'<p>Context Continuity &gt; Skills &gt; Skill - API Trilium ETAPI<br>'
'Contient les patterns API, erreurs connues et fixes pour TriliumNext 0.95+</p>'
)
update_note_content(nid, content)
print('README cree:', nid)
+85
View File
@@ -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 = (
'<h1>Skill - API Trilium ETAPI (TriliumNext 0.95+)</h1>'
'<h2>Contexte</h2>'
'<p>TriliumNext 0.95 a restructure ses routes internes.<br>'
'<code>POST /etapi/notes</code> retourne <em>Router not found</em>.<br>'
'Solution validee : utiliser <strong>trilium-py 1.3.9</strong>.</p>'
'<h2>Installation</h2>'
'<pre>pip install trilium-py python-dotenv</pre>'
'<h2>Authentification</h2>'
'<p>Token dans .env :</p>'
'<pre>TRILIUM_URL=http://localhost:4292\nTRILIUM_TOKEN=token_genere_dans_Options_ETAPI</pre>'
'<p>Header : <code>Authorization: token</code> (sans Bearer)<br>'
'Bearer accepte depuis 0.93 mais non requis.</p>'
'<h2>Wrapper valide : trilium_api.py</h2>'
'<p>Chemin : <code>~/App/Context_continuity/trilium_api.py</code></p>'
'<table>'
'<tr><th>Fonction</th><th>Description</th></tr>'
'<tr><td><code>check_api()</code></td><td>Leve SystemExit si Trilium inaccessible</td></tr>'
'<tr><td><code>create_note(parent_id, title, content=" ", note_type="text")</code></td><td>content=" " obligatoire — pas vide</td></tr>'
'<tr><td><code>get_note_id(result)</code></td><td>Extrait noteId du resultat create_note</td></tr>'
'<tr><td><code>get_note(note_id)</code></td><td>Recupere une note par ID</td></tr>'
'<tr><td><code>get_note_content(note_id)</code></td><td>Recupere le contenu HTML</td></tr>'
'<tr><td><code>update_note_content(note_id, content)</code></td><td>Met a jour le contenu</td></tr>'
'<tr><td><code>search_notes(query, limit=50)</code></td><td>Recherche SANS guillemets autour du terme</td></tr>'
'<tr><td><code>search_by_label(label, value="")</code></td><td>Syntaxe : #label=value</td></tr>'
'<tr><td><code>find_note_by_title(title, parent_id="")</code></td><td>Retourne noteId ou None</td></tr>'
'<tr><td><code>set_label(note_id, name, value="")</code></td><td>isInheritable=False requis en interne</td></tr>'
'<tr><td><code>get_label_value(note_id, name)</code></td><td>Retourne valeur label ou None</td></tr>'
'</table>'
'<h2>Erreurs connues et fixes</h2>'
'<table>'
'<tr><th>Erreur</th><th>Cause</th><th>Fix</th></tr>'
'<tr><td>Router not found POST /etapi/notes</td><td>TriliumNext 0.95 routing change</td><td>Utiliser trilium-py</td></tr>'
'<tr><td>missing argument isInheritable</td><td>create_attribute() trilium-py</td><td>Passer isInheritable=False</td></tr>'
'<tr><td>Note content must be set</td><td>content vide refuse</td><td>Passer content=" "</td></tr>'
'<tr><td>Recherche avec guillemets retourne []</td><td>Parser 0.95</td><td>Chercher sans guillemets</td></tr>'
'<tr><td>{status,code,message} sur create</td><td>Note existe deja ou contenu vide</td><td>find_note_by_title avant + content=" "</td></tr>'
'</table>'
'<h2>Patterns valides</h2>'
'<h3>Creer une note</h3>'
'<pre>result = create_note(parent_id, "Titre", note_type="text")\nnote_id = get_note_id(result)</pre>'
'<h3>Rechercher par label</h3>'
'<pre>notes = search_by_label("type", "backlogItem")\nnotes = search_by_label("projet", "SlidingAutomation")</pre>'
'<h3>Ajouter un attribut</h3>'
'<pre>set_label(note_id, "statut", "actif")</pre>'
'<h3>Workflow bascule LLM</h3>'
'<pre>'
'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'
'</pre>'
'<h2>IDs importants</h2>'
'<p>Stockes dans <code>trilium_ids.json</code> apres <code>python trilium_init.py</code><br>'
'Cles : root, Projets, Conversations, Backlog, Decisions, Historique, Glossaire, ContextesReprise</p>'
'<h2>Recuperer ce skill en contexte LLM</h2>'
'<pre>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"</pre>'
)
update_note_content(nid, content)
print('Skill cree et rempli:', nid)
+170
View File
@@ -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 = (
'<h1>Skill - Structure Context Continuity dans Trilium</h1>'
'<p>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.</p>'
'<h2>Architecture generale</h2>'
'<p>Trilium est organise sous une note racine <strong>Context Continuity</strong> '
'avec 8 sous-dossiers :</p>'
'<table>'
'<tr><th>Dossier</th><th>Contenu</th><th>Cle trilium_ids.json</th></tr>'
'<tr><td>Projets</td><td>Une note par projet suivi</td><td>Projets</td></tr>'
'<tr><td>Conversations</td><td>Une note par session LLM</td><td>Conversations</td></tr>'
'<tr><td>Backlog</td><td>Taches a developper</td><td>Backlog</td></tr>'
'<tr><td>Decisions</td><td>Decisions actives du projet</td><td>Decisions</td></tr>'
'<tr><td>Historique</td><td>Faits etablis, tests effectues, contraintes</td><td>Historique</td></tr>'
'<tr><td>Glossaire</td><td>Vocabulaire specifique au projet</td><td>Glossaire</td></tr>'
'<tr><td>Contextes Reprise</td><td>Briefings generes pour bascule LLM</td><td>ContextesReprise</td></tr>'
'<tr><td>Skills</td><td>Documentation technique pour LLMs</td><td>Skills</td></tr>'
'</table>'
'<h2>Types de notes et leurs labels obligatoires</h2>'
'<h3>Note Projet</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>projet</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces ex: SlidingAutomation</td><td>oui</td></tr>'
'<tr><td>statut</td><td>actif | en-pause | archive</td><td>oui</td></tr>'
'</table>'
'<p>Contenu HTML : objectif, stack technique, repartition LLMs.</p>'
'<h3>Note Conversation</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>conversation</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
'<tr><td>llm</td><td>Claude Sonnet | Claude Opus | Le Chat Large | Le Chat Medium</td><td>oui</td></tr>'
'<tr><td>statut</td><td>en-cours | clos</td><td>oui</td></tr>'
'<tr><td>date</td><td>YYYY-MM-DD HH:MM</td><td>oui</td></tr>'
'<tr><td>syntheseCloture</td><td>texte libre 200 mots max</td><td>non - rempli a la cloture</td></tr>'
'</table>'
'<p>Creee via : <code>python trilium_context.py new-conversation</code></p>'
'<h3>Note Backlog Item</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>backlogItem</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
'<tr><td>statut</td><td>a faire | en cours | bloque | fait | abandonne</td><td>oui</td></tr>'
'<tr><td>priorite</td><td>haute | moyenne | basse</td><td>oui</td></tr>'
'</table>'
'<p>Creee via : <code>python trilium_context.py add-backlog</code> (a implementer) '
'ou manuellement dans Trilium.<br>'
'<strong>ATTENTION</strong> : list-backlog filtre sur statut != "fait" et != "abandonne". '
'Utiliser exactement ces valeurs.</p>'
'<h3>Note Decision</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>decision</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
'<tr><td>statut</td><td>active | revisee | annulee</td><td>oui</td></tr>'
'<tr><td>llm</td><td>nom du LLM ayant aide</td><td>non</td></tr>'
'</table>'
'<p>Creee via : <code>python trilium_context.py add-decision --projet X --enonce "..." --justification "..."</code><br>'
'<strong>generate-context inclut uniquement les decisions avec statut=active.</strong><br>'
'Pour annuler une decision, changer son label statut en "annulee" dans Trilium.</p>'
'<h3>Note Historique</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>historiqueItem</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
'<tr><td>typeHistorique</td><td>Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte</td><td>oui</td></tr>'
'<tr><td>encoreValide</td><td>true | false</td><td>oui</td></tr>'
'</table>'
'<p>Creee via : <code>python trilium_context.py add-history --projet X --type "..." --enonce "..." --detail "..."</code><br>'
'<strong>generate-context inclut uniquement les entrees avec encoreValide=true.</strong><br>'
'Pour invalider une entree obsolete, changer encoreValide en "false" dans Trilium.</p>'
'<h3>Note Terme Glossaire</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>termeGlossaire</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
'<tr><td>definition</td><td>texte court - definition operationnelle</td><td>oui</td></tr>'
'</table>'
'<p><strong>ATTENTION</strong> : le label <code>definition</code> est lu par generate-context '
'pour afficher le glossaire dans le briefing. Sans ce label, la note affiche "(voir note)".<br>'
'Creer manuellement dans Trilium ou via script avec set_label(nid, "definition", "...").</p>'
'<h3>Note Contexte Reprise</h3>'
'<table>'
'<tr><th>Label</th><th>Valeurs possibles</th><th>Obligatoire</th></tr>'
'<tr><td>type</td><td>contexteReprise</td><td>oui</td></tr>'
'<tr><td>projet</td><td>NomSansEspaces</td><td>oui</td></tr>'
'<tr><td>llmCible</td><td>nom du LLM destinataire</td><td>oui</td></tr>'
'<tr><td>version</td><td>entier incremente a chaque reprise</td><td>oui</td></tr>'
'<tr><td>tokens</td><td>estimation tokens du briefing</td><td>non</td></tr>'
'<tr><td>conversationSource</td><td>noteId de la conversation clôturee</td><td>non</td></tr>'
'</table>'
'<p>Creee automatiquement par generate-context. Ne pas creer manuellement.</p>'
'<h2>Ce qu un LLM doit faire pendant une session</h2>'
'<h3>Capitaliser une decision</h3>'
'<p>Des qu une decision technique ou architecturale est prise, demander a l utilisateur :</p>'
'<pre>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"</pre>'
'<h3>Capitaliser un test ou une contrainte</h3>'
'<p>Des qu un test est effectue ou une contrainte decouverte :</p>'
'<pre>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"</pre>'
'<h3>Generer la synthese de cloture</h3>'
'<p>Quand l utilisateur signale que la limite approche, produire :</p>'
'<pre>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]</pre>'
'<p>Format concis, 150-200 mots max, sans markdown complexe '
'(sera colle en argument --summary).</p>'
'<h2>Erreurs frequentes a eviter</h2>'
'<table>'
'<tr><th>Erreur</th><th>Consequence</th><th>Fix</th></tr>'
'<tr><td>Valeur statut avec majuscule ex: "Actif"</td><td>Filtre ne trouve pas la note</td><td>Toujours minuscules : "actif"</td></tr>'
'<tr><td>Nom projet avec espaces ex: "Sliding Automation"</td><td>search_by_label echoue</td><td>Sans espaces : "SlidingAutomation"</td></tr>'
'<tr><td>Label definition absent sur termeGlossaire</td><td>Briefing affiche "(voir note)"</td><td>Ajouter set_label(nid, "definition", "...")</td></tr>'
'<tr><td>encoreValide="True" avec majuscule</td><td>Filtre cherche "true" et ne trouve pas</td><td>Toujours "true" ou "false" minuscules</td></tr>'
'<tr><td>typeHistorique hors liste</td><td>Affichage incoherent dans briefing</td><td>Utiliser exactement : Fait etabli | Test effectue | Hypothese invalidee | Contrainte decouverte</td></tr>'
'</table>'
'<h2>Commande pour recuperer ce skill</h2>'
'<pre>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'
'"</pre>'
)
update_note_content(nid, content)
print('Skill structure cree:', nid)
+819
View File
@@ -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 <pre> (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"<p><b>Justification</b> : {justification or '-'}</p>")
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"<p><b>Type</b> : {type_historique}</p><p><b>Détail</b> : {detail or '-'}</p>")
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"<h2>{full}</h2><table>"
f"<tr><td><b>Projet</b></td><td>{projet}</td></tr>"
f"<tr><td><b>LLM</b></td><td>{llm}</td></tr>"
f"<tr><td><b>Date</b></td><td>{date}</td></tr>"
f"<tr><td><b>Synthèse de clôture</b></td><td><i>À remplir en fin de session</i></td></tr></table>")
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("<", "&lt;").replace(">", "&gt;")
if "À remplir en fin de session" in content:
content = content.replace("<i>À remplir en fin de session</i>", safe)
else:
content += f"<h3>Synthèse</h3><p>{safe}</p>"
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("<", "&lt;").replace(">", "&gt;")
body = "<pre>" + safe + "</pre>"
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("<", "&lt;").replace(">", "&gt;")
res = create_note(ids["Skills"], titre, content=f"<pre>{safe}</pre>")
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 = """<!DOCTYPE html>
<html lang="fr"><head><meta charset="utf-8"><title>Autorisation Trilium MCP</title>
<style>body{{font-family:sans-serif;max-width:400px;margin:80px auto;padding:20px}}
input{{width:100%;padding:10px;margin:10px 0;box-sizing:border-box}}
button{{padding:10px 20px;background:#000;color:#fff;border:none;cursor:pointer}}
.err{{color:#c00}}</style></head>
<body><h2>Autorisation Trilium MCP</h2>
<p>Saisissez le mot de securite pour autoriser l acces.</p>
{error}
<form method="POST" action="/authorize">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
<input type="password" name="security_word" placeholder="Mot de securite" autofocus>
<button type="submit">Autoriser</button>
</form></body></html>"""
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='<p class="err">Mot de securite incorrect.</p>',
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)
+469
View File
@@ -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.
+238
View File
@@ -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
```
+217
View File
@@ -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 <fichiers modifiés>
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 <conteneur>
sudo docker restart <conteneur>
```
---
## 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/<slug>/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
```
+180
View File
@@ -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: <API_KEY>` (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=<token généré dans Options > 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).
+142
View File
@@ -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 <ID_DERNIERE_CONV> --version <N>
```
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 <ID_CONV> --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.
+7
View File
@@ -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
+8
View File
@@ -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
+187
View File
@@ -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 dAnytype 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 larborescence.
- `trilium_api.py` : Bibliothèque pour interagir avec lAPI 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** : Lets 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é | Lets 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 larborescence** | ⏳ 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 larborescence Trilium. |
| **Tester le workflow complet** | ⭐⭐ | Nécessite les scripts et larborescence. |
| **Automatiser la synchronisation** | ⭐ | Nécessite un cron job ou un déclencheur manuel. |
---
## Tests à Effectuer
### 1. **Test de lAPI 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 larborescence.
- [ ] **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** : Lets 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 larborescence.
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) ?
+310
View File
@@ -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="<p>contenu</p>"), \
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="<p>ok</p>"), \
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="<i>À remplir en fin de session</i>"), \
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
+216
View File
@@ -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))
+316
View File
@@ -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`.
+365
View File
@@ -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 <ID> --summary "..."
python trilium_context.py generate-context --projet SlidingAutomation --llm-cible "Le Chat Large" [--note-id <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"""<h2>{titre}</h2>
<table>
<tr><td><b>Projet</b></td><td>{args.projet}</td></tr>
<tr><td><b>LLM</b></td><td>{args.llm}</td></tr>
<tr><td><b>Date</b></td><td>{date}</td></tr>
<tr><td><b>Synthèse de clôture</b></td><td><i>À remplir en fin de session</i></td></tr>
</table>"""
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(
"<i>À remplir en fin de session</i>",
args.summary.replace("<", "&lt;").replace(">", "&gt;")
)
else:
content += f"\n<h3>Synthèse de clôture ({date})</h3><p>{args.summary}</p>"
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", "<br>"))
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"<p><b>Justification</b> : {args.justification}</p><p><b>Date</b> : {date}</p>"
)
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"<p><b>Type</b> : {args.type_h}</p>"
f"<p><b>Détail</b> : {args.detail or ''}</p>"
f"<p><b>Date</b> : {date}</p>"
)
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("<", "&lt;").replace(">", "&gt;")
result = create_note(ids["Skills"], args.titre, f"<pre>{safe}</pre>")
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()
+15
View File
@@ -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"
}
+163
View File
@@ -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,
"<h2>Sliding Automation</h2>"
"<p><b>Objectif</b> : Pipeline de génération automatique de "
"présentations PPTX via agents Mistral.</p>"
"<p><b>Stack</b> : Python, python-pptx, Mistral Large/Small, YAML</p>"
"<p><b>LLM</b> : Claude → architecture &amp; rédaction longue | "
"Le Chat → génération code &amp; itérations</p>"
)
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"<p>{detail}</p>")
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"<p><b>Définition</b> : {definition}</p>")
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()
+64
View File
@@ -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"""<h2>Session {date}</h2>
<table>
<tr><td><b>Titre</b></td><td>{titre}</td></tr>
<tr><td><b>Slides</b></td><td>{nb_slides}</td></tr>
<tr><td><b>YAML</b></td><td>{yaml_path}</td></tr>
<tr><td><b>PPTX</b></td><td>{pptx_path}</td></tr>
<tr><td><b>Layouts</b></td><td>{layouts_str}</td></tr>
<tr><td><b>Date</b></td><td>{date}</td></tr>
</table>"""
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}")
+49
View File
@@ -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"