diff --git a/README.md b/README.md
index e00ebcf..3cf6ddb 100644
--- a/README.md
+++ b/README.md
@@ -16,9 +16,17 @@ homelab-scripts/
├── maintenance/ # Tâches de fond
│ ├── archive-old-sessions.sh # Archive sessions Hermes (3h)
│ └── check-obsolete.sh # Rapport éléments obsolètes (lundi)
-└── web/ # Surveillance sites
- ├── site-test.py # Audit sécurité/UX laurentkeller.org
- └── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos
+├── web/ # Surveillance sites
+│ ├── site-test.py # Audit sécurité/UX laurentkeller.org
+│ └── suivi-quotidien.py # Suivi quotidien Mon-site-web-photos
+└── one-shot/ # Scripts ponctuels (réutilisables)
+ ├── plan_maison.py # Plan d'étage matplotlib
+ ├── generate-honcho-pdf.py # PDF architecture Honcho
+ ├── generate_gramps_report.py # Rapport généalogie PDF
+ ├── google-workspace-mcp-login.py # OAuth Google Workspace
+ ├── oauth-step1.py / oauth-step2.py # Flow OAuth
+ ├── populate_gramps_sheet.py # Gramps → Google Sheet
+ └── fix_maxbuffer.py # Fix maxBuffer wiremcp-sse
```
## Déploiement
diff --git a/one-shot/fix_maxbuffer.py b/one-shot/fix_maxbuffer.py
new file mode 100644
index 0000000..521a681
--- /dev/null
+++ b/one-shot/fix_maxbuffer.py
@@ -0,0 +1,33 @@
+import re
+
+with open('/opt/WireMCP/index.js', 'r') as f:
+ content = f.read()
+
+# Replace the promisify line to include maxBuffer default
+content = content.replace(
+ 'const execAsync = promisify(exec);',
+ 'const execAsync = promisify(exec);\nconst MAX_BUFFER = 100 * 1024 * 1024; // 100MB for large packet captures'
+)
+
+# Remove duplicate if any
+lines = content.split('\n')
+seen = set()
+unique_lines = []
+for line in lines:
+ if 'MAX_BUFFER' in line and line in seen:
+ continue
+ if 'MAX_BUFFER' in line:
+ seen.add(line)
+ unique_lines.append(line)
+content = '\n'.join(unique_lines)
+
+# Now wrap execAsync to pass maxBuffer by default
+content = content.replace(
+ 'const execAsync = promisify(exec);\nconst MAX_BUFFER = 100 * 1024 * 1024;',
+ 'const _execAsync = promisify(exec);\nconst MAX_BUFFER = 100 * 1024 * 1024;\nconst execAsync = (cmd, opts = {}) => _execAsync(cmd, { maxBuffer: MAX_BUFFER, ...opts });'
+)
+
+with open('/opt/WireMCP/index.js', 'w') as f:
+ f.write(content)
+
+print('OK')
diff --git a/one-shot/generate-honcho-pdf.py b/one-shot/generate-honcho-pdf.py
new file mode 100644
index 0000000..2b8cf01
--- /dev/null
+++ b/one-shot/generate-honcho-pdf.py
@@ -0,0 +1,550 @@
+#!/usr/bin/env python3
+"""Génère un PDF expliquant l'architecture Honcho + Hermes"""
+
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+from reportlab.lib.units import mm, cm
+from reportlab.lib.colors import HexColor, black, white
+from reportlab.lib.enums import TA_LEFT, TA_CENTER
+from reportlab.platypus import (
+ SimpleDocTemplate, Paragraph, Spacer, PageBreak,
+ Table, TableStyle, ListFlowable, ListItem, KeepTogether
+)
+from reportlab.platypus.flowables import HRFlowable
+from reportlab.lib import colors
+import os
+
+OUTPUT = "/root/workspace/honcho-hermes-architecture.pdf"
+
+# Couleurs
+DARK = HexColor("#0f172a")
+ACCENT = HexColor("#3b82f6")
+GREEN = HexColor("#22c55e")
+GRAY = HexColor("#64748b")
+LIGHT = HexColor("#f1f5f9")
+ORANGE = HexColor("#f59e0b")
+
+doc = SimpleDocTemplate(
+ OUTPUT,
+ pagesize=A4,
+ topMargin=2*cm,
+ bottomMargin=2*cm,
+ leftMargin=2.5*cm,
+ rightMargin=2.5*cm,
+)
+
+styles = getSampleStyleSheet()
+
+# Styles personnalisés
+styles.add(ParagraphStyle(
+ "CoverTitle", parent=styles["Title"],
+ fontSize=28, leading=34, textColor=DARK,
+ spaceAfter=6*mm, alignment=TA_CENTER,
+ fontName="Helvetica-Bold"
+))
+styles.add(ParagraphStyle(
+ "CoverSub", parent=styles["Normal"],
+ fontSize=14, leading=18, textColor=GRAY,
+ spaceAfter=4*mm, alignment=TA_CENTER,
+ fontName="Helvetica"
+))
+styles.add(ParagraphStyle(
+ "SectionTitle", parent=styles["Heading1"],
+ fontSize=18, leading=22, textColor=DARK,
+ spaceBefore=10*mm, spaceAfter=4*mm,
+ fontName="Helvetica-Bold"
+))
+styles.add(ParagraphStyle(
+ "SubTitle", parent=styles["Heading2"],
+ fontSize=14, leading=18, textColor=ACCENT,
+ spaceBefore=6*mm, spaceAfter=3*mm,
+ fontName="Helvetica-Bold"
+))
+styles.add(ParagraphStyle(
+ "Body", parent=styles["Normal"],
+ fontSize=10, leading=14, textColor=HexColor("#334155"),
+ spaceAfter=3*mm,
+ fontName="Helvetica"
+))
+# "Code" style already exists in default stylesheet
+styles.add(ParagraphStyle(
+ "MyBullet", parent=styles["Normal"],
+ fontSize=10, leading=14, textColor=HexColor("#334155"),
+ leftIndent=8*mm, spaceAfter=2*mm,
+ fontName="Helvetica"
+))
+styles.add(ParagraphStyle(
+ "MySmallNote", parent=styles["Normal"],
+ fontSize=8, leading=10, textColor=GRAY,
+ spaceAfter=2*mm, fontName="Helvetica-Oblique"
+))
+
+story = []
+
+# ===== PAGE DE COUVERTURE =====
+story.append(Spacer(1, 4*cm))
+story.append(Paragraph("Honcho + Hermes", styles["CoverTitle"]))
+story.append(Paragraph("Architecture & Déploiement", styles["CoverSub"]))
+story.append(Spacer(1, 3*mm))
+story.append(HRFlowable(width="60%", thickness=1, color=ACCENT))
+story.append(Spacer(1, 5*mm))
+story.append(Paragraph("Mémoire persistante cross-session pour Hermes Agent", styles["CoverSub"]))
+story.append(Spacer(1, 2*cm))
+story.append(Paragraph("Laurent Keller — Homelab 2026", styles["MySmallNote"]))
+story.append(PageBreak())
+
+# ===== TABLE DES MATIÈRES =====
+story.append(Paragraph("Table des matières", styles["SectionTitle"]))
+story.append(Spacer(1, 3*mm))
+toc_items = [
+ "1. Qu'est-ce que Honcho ?",
+ "2. Architecture globale",
+ "3. Composants déployés",
+ "4. Flux de fonctionnement",
+ "5. Configuration Hermes",
+ "6. Commandes utiles",
+ "7. Schéma réseau",
+ "8. Dépannage",
+]
+for item in toc_items:
+ story.append(Paragraph(item, styles["MyBullet"]))
+story.append(PageBreak())
+
+# ===== 1. QU'EST-CE QUE HONCHO =====
+story.append(Paragraph("1. Qu'est-ce que Honcho ?", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph(
+ "Honcho est une infrastructure mémoire open-source (AGPL-3.0) développée par Plastic Labs. "
+ "Elle permet aux agents AI de construire une représentation persistante de l'utilisateur "
+ "à travers les sessions — contrairement à une mémoire fichier classique qui ne fait que "
+ "stocker du texte brut.",
+ styles["Body"]
+))
+
+story.append(Paragraph("Ce que Honcho apporte à Hermes :", styles["SubTitle"]))
+bullets = [
+ "Mémoire cross-session — l'agent se souvient de qui vous êtes entre deux conversations",
+ "Représentation utilisateur — Honcho analyse les conversations et extrait des conclusions (préférences, patterns, style de communication)",
+ "Dialectic engine — un moteur de raisonnement en arrière-plan qui synthétise la mémoire",
+ "Recherche sémantique — retrouver des informations par similarité de sens, pas par mot-clé",
+ "Multi-peers — chaque profil Hermes a sa propre identité AI tout en partageant la vue utilisateur",
+]
+for b in bullets:
+ story.append(Paragraph(b, styles["MyBullet"]))
+
+story.append(Spacer(1, 3*mm))
+story.append(Paragraph("Le cœur du système :", styles["SubTitle"]))
+story.append(Paragraph(
+ "1. Store — les conversations sont stockées dans PostgreSQL avec embeddings vectoriels
"
+ "2. Reason — le Deriver (worker background) analyse les messages et met à jour la représentation
"
+ "3. Query — Hermes interroge Honcho via 5 outils pour récupérer le contexte
"
+ "4. Inject — le contexte est injecté dans le system prompt à chaque tour",
+ styles["Body"]
+))
+story.append(PageBreak())
+
+# ===== 2. ARCHITECTURE GLOBALE =====
+story.append(Paragraph("2. Architecture globale", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph(
+ "Le déploiement s'appuie sur 3 VMs du homelab, interconnectées via le réseau 192.168.30.0/24 :",
+ styles["Body"]
+))
+
+# Tableau des VMs
+vm_data = [
+ ["VM", "IP", "Rôle", "Services"],
+ ["Kellerflix\n(VM 101)", "192.168.30.10", "LLM & Embeddings", "Ollama\nnemotron-mini:4b\nnomic-embed-text"],
+ ["postgres\n(LXC/VM)", "192.168.30.35", "Honcho Server", "Honcho API (:8000)\nHoncho Deriver\nPostgreSQL pgvector (:5434)"],
+ ["redis\n(LXC/VM)", "192.168.30.36", "Cache", "Redis (:6379)"],
+]
+vm_table = Table(vm_data, colWidths=[3.5*cm, 3*cm, 3*cm, 5.5*cm])
+vm_table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), DARK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 0.5, GRAY),
+ ("BACKGROUND", (0, 1), (-1, -1), LIGHT),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+]))
+story.append(vm_table)
+story.append(Spacer(1, 5*mm))
+
+story.append(Paragraph("Flux des données :", styles["SubTitle"]))
+story.append(Paragraph(
+ "Hermes (LXC 112) → Honcho API (192.168.30.35:8000) → PostgreSQL pgvector
"
+ "Honcho Deriver → Ollama (192.168.30.10:11434) pour LLM + embeddings
"
+ "Honcho API → Redis (192.168.30.36:6379) pour cache",
+ styles["Body"]
+))
+story.append(PageBreak())
+
+# ===== 3. COMPOSANTS DÉPLOYÉS =====
+story.append(Paragraph("3. Composants déployés", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+# 3.1 Honcho API
+story.append(Paragraph("3.1 Honcho API (FastAPI)", styles["SubTitle"]))
+story.append(Paragraph(
+ "Serveur HTTP qui expose l'API REST de Honcho. Point d'entrée pour Hermes et le SDK Python. "
+ "Gère les sessions, peers, messages, et la recherche contextuelle.",
+ styles["Body"]
+))
+story.append(Paragraph(
+ "Port : 8000
"
+ "Healthcheck : GET /health → {\"status\":\"ok\"}
"
+ "Docker : honcho-api (build local depuis le Dockerfile du repo plastic-labs/honcho)",
+ styles["Body"]
+))
+
+# 3.2 Honcho Deriver
+story.append(Paragraph("3.2 Honcho Deriver (Worker background)", styles["SubTitle"]))
+story.append(Paragraph(
+ "Processus qui tourne en arrière-plan et analyse les messages stockés. "
+ "Il utilise le LLM configuré (nemotron-mini:4b) pour :",
+ styles["Body"]
+))
+bullets = [
+ "Extraire des conclusions sur l'utilisateur (préférences, habitudes)",
+ "Générer des résumés de sessions",
+ "Mettre à jour la représentation du peer",
+ "Alimenter le moteur dialectic pour honcho_reasoning",
+]
+for b in bullets:
+ story.append(Paragraph(b, styles["MyBullet"]))
+
+# 3.3 PostgreSQL pgvector
+story.append(Paragraph("3.3 PostgreSQL + pgvector", styles["SubTitle"]))
+story.append(Paragraph(
+ "Base de données vectorielle. Stocke les messages, sessions, peers, et les embeddings "
+ "(vecteurs à 768 dimensions pour nomic-embed-text). L'extension pgvector permet la "
+ "recherche par similarité sémantique.",
+ styles["Body"]
+))
+story.append(Paragraph(
+ "Image : pgvector/pgvector:pg15
"
+ "Port interne : 5434 (évite conflit avec le PostgreSQL existant sur 5432)
"
+ "Volume : honcho-pgdata (persistant)",
+ styles["Body"]
+))
+
+# 3.4 Redis
+story.append(Paragraph("3.4 Redis (Cache)", styles["SubTitle"]))
+story.append(Paragraph(
+ "Cache pour accélérer les requêtes répétées. Redis existant sur le homelab, "
+ "réutilisé sans conteneur dédié dans le stack Honcho.",
+ styles["Body"]
+))
+story.append(Paragraph(
+ "Hôte : 192.168.30.36:6379
"
+ "Pas de mot de passe (réseau interne)",
+ styles["Body"]
+))
+
+# 3.5 Ollama
+story.append(Paragraph("3.5 Ollama (LLM & Embeddings)", styles["SubTitle"]))
+story.append(Paragraph(
+ "Serveur de modèles LLM locaux sur Kellerflix (GTX 1070, 8GB VRAM). "
+ "Deux modèles utilisés :",
+ styles["Body"]
+))
+bullets = [
+ "nemotron-mini:4b (Q4_K_M, 2.7GB) — LLM pour le dialectic engine et le deriver. Supporte le tool calling.",
+ "nomic-embed-text (274MB) — modèle d'embeddings pour la recherche vectorielle (768 dimensions).",
+]
+for b in bullets:
+ story.append(Paragraph(b, styles["MyBullet"]))
+story.append(PageBreak())
+
+# ===== 4. FLUX DE FONCTIONNEMENT =====
+story.append(Paragraph("4. Flux de fonctionnement", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph("4.1 À chaque message utilisateur", styles["SubTitle"]))
+steps = [
+ "1. Hermes reçoit le message de l'utilisateur",
+ "2. Honcho injecte automatiquement le contexte dans le system prompt (représentation utilisateur + résumé de session + carte AI peer)",
+ "3. Hermes traite la requête avec ce contexte",
+ "4. La réponse est envoyée à l'utilisateur",
+ "5. Le message est stocké dans Honcho (via l'API)",
+ "6. Le Deriver analyse le message en arrière-plan et met à jour la représentation",
+]
+for s in steps:
+ story.append(Paragraph(s, styles["MyBullet"]))
+
+story.append(Paragraph("4.2 Modes de rappel (Recall Modes)", styles["SubTitle"]))
+recall_data = [
+ ["Mode", "Auto-injection", "Outils mémoire", "Usage"],
+ ["hybrid\n(default)", "Oui", "Oui (5 outils)", "L'agent décide quand utiliser les outils vs le contexte auto-injecté"],
+ ["context", "Oui", "Non (cachés)", "Économique en tokens, pas d'appels outils"],
+ ["tools", "Non", "Oui", "L'agent contrôle tout explicitement"],
+]
+recall_table = Table(recall_data, colWidths=[3*cm, 2.5*cm, 2.5*cm, 6*cm])
+recall_table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), DARK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 0.5, GRAY),
+ ("BACKGROUND", (0, 1), (-1, -1), LIGHT),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+]))
+story.append(recall_table)
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph("4.3 Les 5 outils mémoire", styles["SubTitle"]))
+tools_data = [
+ ["Outil", "Appel LLM ?", "Coût", "Usage"],
+ ["honcho_profile", "Non", "minimal", "Lire/mettre à jour la carte du peer (nom, rôle, préférences)"],
+ ["honcho_search", "Non", "faible", "Recherche sémantique dans l'historique"],
+ ["honcho_context", "Non", "faible", "Snapshot complet : résumé + représentation + messages récents"],
+ ["honcho_reasoning", "Oui", "moyen-élevé", "Question synthétisée par le dialectic engine"],
+ ["honcho_conclude", "Non", "minimal", "Écrire/supprimer une conclusion persistante"],
+]
+tools_table = Table(tools_data, colWidths=[3*cm, 2*cm, 2*cm, 7*cm])
+tools_table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), DARK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 0.5, GRAY),
+ ("BACKGROUND", (0, 1), (-1, -1), LIGHT),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+]))
+story.append(tools_table)
+story.append(PageBreak())
+
+# ===== 5. CONFIGURATION HERMES =====
+story.append(Paragraph("5. Configuration Hermes", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph("5.1 Fichier honcho.json", styles["SubTitle"]))
+story.append(Paragraph(
+ "Chemin : /root/.hermes/honcho.json",
+ styles["Body"]
+))
+story.append(Paragraph(
+ "{
"
+ " \"baseUrl\": \"http://192.168.30.35:8000\",
"
+ " \"peerName\": \"laurent\",
"
+ " \"aiPeer\": \"hermes\",
"
+ " \"workspace\": \"default\",
"
+ " \"recallMode\": \"hybrid\",
"
+ " \"writeFrequency\": \"async\",
"
+ " \"sessionStrategy\": \"per-directory\",
"
+ " \"observation\": {
"
+ " \"user\": { \"observeMe\": true, \"observeOthers\": true },
"
+ " \"ai\": { \"observeMe\": true, \"observeOthers\": true }
"
+ " },
"
+ " \"dialecticReasoningLevel\": \"low\",
"
+ " \"dialecticDepth\": 1,
"
+ " \"contextCadence\": 1,
"
+ " \"dialecticCadence\": 2
"
+ "}",
+ styles["Code"]
+))
+
+story.append(Paragraph("5.2 Config Hermes", styles["SubTitle"]))
+story.append(Paragraph(
+ "Dans /root/.hermes/config.yaml :",
+ styles["Body"]
+))
+story.append(Paragraph(
+ "memory:
"
+ " provider: honcho",
+ styles["Code"]
+))
+
+story.append(Paragraph("5.3 Fichier .env Honcho", styles["SubTitle"]))
+story.append(Paragraph(
+ "Chemin sur la VM postgres : /root/docker/honcho/.env",
+ styles["Body"]
+))
+story.append(Paragraph(
+ "LOG_LEVEL=INFO
"
+ "AUTH_USE_AUTH=false
"
+ "VECTOR_STORE_TYPE=pgvector
"
+ "LLM_OPENAI_API_KEY=ollama
"
+ "DERIVER_MODEL_CONFIG__TRANSPORT=openai
"
+ "DERIVER_MODEL_CONFIG__MODEL=nemotron-mini:4b
"
+ "DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=http://192.168.30.10:11434/v1
"
+ "DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=ollama
"
+ "EMBEDDING_MODEL_CONFIG__TRANSPORT=openai
"
+ "EMBEDDING_MODEL_CONFIG__MODEL=nomic-embed-text
"
+ "EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://192.168.30.10:11434/v1
"
+ "EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=ollama
"
+ "EMBED_MESSAGES=true
"
+ "EMBEDDING_VECTOR_DIMENSIONS=768",
+ styles["Code"]
+))
+story.append(PageBreak())
+
+# ===== 6. COMMANDES UTILES =====
+story.append(Paragraph("6. Commandes utiles", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph("6.1 Sur Hermes (LXC 112)", styles["SubTitle"]))
+cmds = [
+ ("hermes honcho status", "Voir l'état de la connexion Honcho"),
+ ("hermes honcho setup", "Lancer le wizard de configuration"),
+ ("hermes honcho enable/disable", "Activer/désactiver Honcho"),
+ ("hermes honcho peer --user ", "Définir le nom du peer utilisateur"),
+ ("hermes honcho mode hybrid|context|tools", "Changer le mode de rappel"),
+ ("hermes honcho sessions", "Lister les sessions connues"),
+ ("hermes honcho map ", "Mapper le répertoire courant à une session"),
+ ("hermes honcho sync", "Créer les host blocks pour tous les profils"),
+ ("hermes memory status", "Voir le provider mémoire actif"),
+ ("hermes memory off", "Désactiver la mémoire externe"),
+]
+for cmd, desc in cmds:
+ story.append(Paragraph(f"{cmd} — {desc}", styles["MyBullet"]))
+
+story.append(Paragraph("6.2 Sur la VM postgres (192.168.30.35)", styles["SubTitle"]))
+cmds2 = [
+ ("docker compose ps", "Voir l'état des conteneurs"),
+ ("docker compose logs api --tail 30", "Logs de l'API Honcho"),
+ ("docker compose logs deriver --tail 30", "Logs du Deriver"),
+ ("docker compose down && docker compose up -d", "Redémarrer le stack"),
+ ("docker compose down -v && docker compose up -d --build", "Reset complet + rebuild"),
+ ("curl http://localhost:8000/health", "Healthcheck"),
+]
+for cmd, desc in cmds2:
+ story.append(Paragraph(f"{cmd} — {desc}", styles["MyBullet"]))
+
+story.append(Paragraph("6.3 Sur Kellerflix (192.168.30.10)", styles["SubTitle"]))
+cmds3 = [
+ ("ollama list", "Lister les modèles disponibles"),
+ ("ollama pull ", "Télécharger un modèle"),
+ ("ollama run ", "Tester un modèle en interactif"),
+ ("curl http://localhost:11434/api/tags", "Lister les modèles via API"),
+]
+for cmd, desc in cmds3:
+ story.append(Paragraph(f"{cmd} — {desc}", styles["MyBullet"]))
+story.append(PageBreak())
+
+# ===== 7. SCHÉMA RÉSEAU =====
+story.append(Paragraph("7. Schéma réseau", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+story.append(Paragraph(
+ "Le schéma ci-dessous représente les flux entre les composants :",
+ styles["Body"]
+))
+story.append(Spacer(1, 3*mm))
+
+# Schéma ASCII
+schema = """
+┌─────────────────────────────────────────────────────────────┐
+│ RÉSEAU 192.168.30.0/24 │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────────────┐ ┌──────────────────────────────┐ │
+│ │ Hermes Agent │ │ VM postgres (30.35) │ │
+│ │ (LXC 112) │ │ │ │
+│ │ │ │ ┌──────────────────────┐ │ │
+│ │ honcho-ai SDK │────▶│ │ Honcho API (:8000) │ │ │
+│ │ hermes honcho │ │ │ (FastAPI) │ │ │
+│ │ │ │ └──────┬───────────────┘ │ │
+│ └──────────────────┘ │ │ │ │
+│ │ ▼ │ │
+│ │ ┌──────────────────────┐ │ │
+│ │ │ PostgreSQL pgvector │ │ │
+│ │ │ (:5434) │ │ │
+│ │ └──────────────────────┘ │ │
+│ │ ▲ │ │
+│ │ ┌──────┴───────────────┐ │ │
+│ │ │ Honcho Deriver │ │ │
+│ │ │ (worker background) │ │ │
+│ │ └──────┬───────────────┘ │ │
+│ └─────────┼────────────────────┘ │
+│ │ │
+│ ┌─────────┼────────────┐ │
+│ │ ▼ │ │
+│ │ ┌──────────────┐ │ │
+│ │ │ Redis │ │ │
+│ │ │ (30.36:6379) │ │ │
+│ │ └──────────────┘ │ │
+│ └──────────────────────┘ │
+│ │ │
+│ ┌─────────┼────────────┐ │
+│ │ ▼ │ │
+│ │ ┌──────────────────┐ │ │
+│ │ │ Kellerflix │ │ │
+│ │ │ (30.10:11434) │ │ │
+│ │ │ Ollama │ │ │
+│ │ │ ├─ nemotron-mini│ │ │
+│ │ │ └─ nomic-embed │ │ │
+│ │ └──────────────────┘ │ │
+│ └────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────┘
+"""
+story.append(Paragraph(schema, styles["Body"]))
+story.append(PageBreak())
+
+# ===== 8. DÉPANNAGE =====
+story.append(Paragraph("8. Dépannage", styles["SectionTitle"]))
+story.append(HRFlowable(width="100%", thickness=0.5, color=ACCENT))
+story.append(Spacer(1, 3*mm))
+
+issues = [
+ ("Problème", "Cause probable", "Solution"),
+ ("Honcho not configured", "memory.provider pas défini", "hermes config set memory.provider honcho"),
+ ("API Honcho injoignable", "Conteneur down", "docker compose ps && docker compose logs api"),
+ ("OpenAI API key required", "LLM_OPENAI_API_KEY manquant", "Ajouter LLM_OPENAI_API_KEY=ollama dans .env"),
+ ("Embedding dim mismatch", "Dimensions changées après init", "docker compose down -v && docker compose up -d --build"),
+ ("Deriver ne tourne pas", "Dépendance api pas healthy", "Vérifier les logs : docker compose logs deriver"),
+ ("Peer conflict", "Peer déjà créé", "Utiliser un nom différent ou supprimer l'ancien"),
+ ("Ollama inaccessible", "OLLAMA_HOST=127.0.0.1", "Vérifier ss -tlnp | grep 11434"),
+ ("Mémoire non persistante", "writeFrequency = session", "Passer à async dans honcho.json"),
+]
+issues_table = Table(issues, colWidths=[3.5*cm, 4*cm, 6.5*cm])
+issues_table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), DARK),
+ ("TEXTCOLOR", (0, 0), (-1, 0), white),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
+ ("ALIGN", (0, 0), (-1, -1), "LEFT"),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ("GRID", (0, 0), (-1, -1), 0.5, GRAY),
+ ("BACKGROUND", (0, 1), (-1, -1), LIGHT),
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
+]))
+story.append(issues_table)
+story.append(Spacer(1, 5*mm))
+
+story.append(Paragraph("Liens utiles :", styles["SubTitle"]))
+story.append(Paragraph(
+ "Documentation Honcho : https://docs.honcho.dev
"
+ "Repo Honcho : https://github.com/plastic-labs/honcho
"
+ "Intégration Hermes : https://docs.honcho.dev/v3/guides/integrations/hermes
"
+ "App Honcho Cloud : https://app.honcho.dev
"
+ "Ollama : https://ollama.com",
+ styles["Body"]
+))
+
+# Build
+doc.build(story)
+print(f"PDF généré : {OUTPUT}")
+print(f"Taille : {os.path.getsize(OUTPUT)} bytes")
diff --git a/one-shot/generate_gramps_report.py b/one-shot/generate_gramps_report.py
new file mode 100644
index 0000000..1500426
--- /dev/null
+++ b/one-shot/generate_gramps_report.py
@@ -0,0 +1,760 @@
+#!/usr/bin/env python3
+"""Generate a structured PDF genealogy report from Gramps JSONL export."""
+
+import json
+import sys
+from collections import defaultdict
+from datetime import datetime
+from fpdf import FPDF
+
+# ── Config ──────────────────────────────────────────────────────────────────
+INPUT = "/root/.hermes/webui/attachments/b3196dc4d94f/gramps-web-export-20260722234742.json"
+
+# ── Load data ──────────────────────────────────────────────────────────────
+objects = []
+with open(INPUT) as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ if "|" in line and line[0].isdigit():
+ line = line.split("|", 1)[1]
+ objects.append(json.loads(line))
+
+by_handle = {o["handle"]: o for o in objects}
+
+# ── Event type mapping ───────────────────────────────────────────────────────
+EVENT_TYPES = {
+ 0: "Mariage", 1: "Mariage", 2: "Divorce",
+ 3: "Décès", 4: "Inhumation", 5: "Obsèques",
+ 6: "Naissance", 7: "Baptême", 8: "Adoption",
+ 9: "Baptême (LDS)", 10: "Endowment (LDS)",
+ 11: "Scellement (LDS)", 12: "Naissance", 13: "Décès",
+ 19: "Inhumation",
+}
+
+def event_type_str(t):
+ return EVENT_TYPES.get(t.get("value"), f"Type {t.get('value')}")
+
+def format_date(d):
+ if not d:
+ return ""
+ dv = d.get("dateval", [0, 0, 0])
+ y, m, day = dv[2], dv[1], dv[0]
+ if y == 0:
+ return ""
+ if m == 0:
+ return str(y)
+ if day == 0:
+ months = ["", "Jan", "Fév", "Mar", "Avr", "Mai", "Juin",
+ "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc"]
+ return f"{months[m]} {y}"
+ return f"{day:02d}/{m:02d}/{y}"
+
+def person_name(p):
+ if not p:
+ return "Inconnu"
+ n = p.get("primary_name", {})
+ first = n.get("first_name", "").strip()
+ sur_list = n.get("surname_list", [])
+ sur = sur_list[0].get("surname", "").strip() if sur_list else ""
+ suffix = n.get("suffix", "").strip()
+ parts = [first, sur, suffix] if suffix else [first, sur]
+ return " ".join(p for p in parts if p).strip()
+
+def get_person_events(p):
+ birth, death = "", ""
+ for er in p.get("event_ref_list", []):
+ ev = by_handle.get(er.get("ref"))
+ if ev:
+ t = ev.get("type", {}).get("value")
+ d = format_date(ev.get("date"))
+ if t == 12:
+ birth = d
+ elif t == 13:
+ death = d
+ return birth, death
+
+def get_events_for_person(p):
+ events = []
+ for er in p.get("event_ref_list", []):
+ ev = by_handle.get(er.get("ref"))
+ if ev:
+ events.append({
+ "type": event_type_str(ev.get("type", {})),
+ "date": format_date(ev.get("date")),
+ "desc": ev.get("description", ""),
+ "place": ev.get("place", ""),
+ })
+ return events
+
+def get_parents(person):
+ father, mother = None, None
+ for pf in person.get("parent_family_list", []):
+ fam = by_handle.get(pf)
+ if fam:
+ if fam.get("father_handle"):
+ father = by_handle.get(fam["father_handle"])
+ if fam.get("mother_handle"):
+ mother = by_handle.get(fam["mother_handle"])
+ return father, mother
+
+def get_families(person):
+ return [f for f in families
+ if f.get("father_handle") == person["handle"]
+ or f.get("mother_handle") == person["handle"]]
+
+def get_children(family):
+ children = []
+ for cr in family.get("child_ref_list", []):
+ child = by_handle.get(cr.get("ref"))
+ if child:
+ children.append(child)
+ return children
+
+def get_place_name(handle):
+ pl = by_handle.get(handle)
+ if pl:
+ return pl.get("name", {}).get("value", "")
+ return ""
+
+def get_notes_for_person(p):
+ notes = []
+ for nh in p.get("note_list", []):
+ n = by_handle.get(nh)
+ if n:
+ notes.append(n.get("text", {}).get("string", ""))
+ return notes
+
+# ── Index data ──────────────────────────────────────────────────────────────
+persons = [o for o in objects if o.get("_class") == "Person"]
+families = [o for o in objects if o.get("_class") == "Family"]
+places = [o for o in objects if o.get("_class") == "Place"]
+media = [o for o in objects if o.get("_class") == "Media"]
+notes = [o for o in objects if o.get("_class") == "Note"]
+
+# ── PDF Generation ───────────────────────────────────────────────────────────
+
+class GenealogyPDF(FPDF):
+ def header(self):
+ if self.page_no() > 1:
+ self.set_font("Helvetica", "I", 7)
+ self.set_text_color(130, 130, 130)
+ self.cell(0, 5, "Rapport de Genealogie - Familles Keller, Staerle & Alliees", align="C")
+ self.ln(7)
+
+ def footer(self):
+ self.set_y(-15)
+ self.set_font("Helvetica", "I", 7)
+ self.set_text_color(130, 130, 130)
+ self.cell(0, 8, f"Page {self.page_no()}/{{nb}}", align="C")
+
+ def section_title(self, title):
+ self.set_font("Helvetica", "B", 14)
+ self.set_text_color(15, 23, 42)
+ self.cell(0, 8, title)
+ self.ln(4)
+ self.set_draw_color(15, 23, 42)
+ self.set_line_width(0.5)
+ self.line(self.get_x(), self.get_y(), self.get_x() + 190, self.get_y())
+ self.ln(6)
+
+ def sub_title(self, title):
+ self.set_font("Helvetica", "B", 11)
+ self.set_text_color(30, 60, 100)
+ self.cell(0, 7, title)
+ self.ln(5)
+
+ def body_text(self, text):
+ self.set_font("Helvetica", "", 9)
+ self.set_text_color(50, 50, 50)
+ w = self.w - self.get_x() - self.r_margin
+ self.multi_cell(w, 4.5, text)
+ self.ln(1)
+
+ def bullet(self, text):
+ self.set_font("Helvetica", "", 9)
+ self.set_text_color(50, 50, 50)
+ x0 = self.get_x()
+ self.cell(5, 4.5, "-")
+ w = self.w - self.get_x() - self.r_margin
+ self.multi_cell(w, 4.5, text)
+ self.ln(0.5)
+
+ def kv(self, key, value):
+ self.set_font("Helvetica", "B", 9)
+ self.set_text_color(50, 50, 50)
+ self.cell(40, 5, key)
+ self.set_font("Helvetica", "", 9)
+ w = self.w - self.get_x() - self.r_margin
+ self.multi_cell(w, 5, value)
+ self.ln(0.5)
+
+ def person_card(self, p, level=0):
+ name = person_name(p)
+ birth, death = get_person_events(p)
+ gender_map = {0: "F", 1: "M", 2: "?"}
+ gender = gender_map.get(p.get("gender"), "?")
+
+ indent = level * 4
+ self.set_x(self.l_margin + indent)
+
+ self.set_font("Helvetica", "B", 10)
+ self.set_text_color(15, 23, 42)
+ self.cell(0, 5, f"{name} [{gender}] ({p.get('gramps_id', '')})")
+ self.ln(5)
+
+ self.set_x(self.l_margin + indent + 4)
+ self.set_font("Helvetica", "", 9)
+ self.set_text_color(80, 80, 80)
+ dates = []
+ if birth:
+ dates.append(f"Ne(e) le {birth}")
+ if death:
+ dates.append(f"Decede(e) le {death}")
+ if dates:
+ self.cell(0, 4.5, " | ".join(dates))
+ self.ln(4.5)
+
+ events = get_events_for_person(p)
+ for ev in events:
+ if ev["type"] not in ("Naissance", "Décès"):
+ self.set_x(self.l_margin + indent + 4)
+ self.set_font("Helvetica", "", 8)
+ self.set_text_color(100, 100, 100)
+ txt = f"{ev['type']}: {ev['date']}" if ev["date"] else ev["type"]
+ if ev["place"]:
+ place_name = get_place_name(ev["place"])
+ if place_name:
+ txt += f" - {place_name}"
+ if ev["desc"]:
+ txt += f" ({ev['desc']})"
+ self.cell(0, 4, txt)
+ self.ln(4)
+
+ # Notes (professions, etc.)
+ p_notes = get_notes_for_person(p)
+ for n in p_notes:
+ self.set_x(self.l_margin + indent + 4)
+ self.set_font("Helvetica", "I", 8)
+ self.set_text_color(120, 120, 120)
+ self.cell(0, 4, f"[{n.strip()}]")
+ self.ln(4)
+
+ self.ln(2)
+
+ def family_section(self, family, label=""):
+ father = by_handle.get(family.get("father_handle"))
+ mother = by_handle.get(family.get("mother_handle"))
+ children = get_children(family)
+
+ f_name = person_name(father) if father else "?"
+ m_name = person_name(mother) if mother else "?"
+
+ marriage_date = ""
+ marriage_place = ""
+ for er in family.get("event_ref_list", []):
+ ev = by_handle.get(er.get("ref"))
+ if ev:
+ marriage_date = format_date(ev.get("date"))
+ marriage_place = get_place_name(ev.get("place", ""))
+
+ rel_type = family.get("type", {}).get("value", 0)
+ rel_str = "Marie" if rel_type == 0 else "Non marie" if rel_type == 3 else ""
+
+ title = f"{f_name} & {m_name}"
+ if label:
+ title = f"{label}: {title}"
+ self.sub_title(title)
+
+ if marriage_date:
+ txt = marriage_date
+ if marriage_place:
+ txt += f" - {marriage_place}"
+ self.kv("Mariage:", txt)
+ if rel_str:
+ self.kv("Statut:", rel_str)
+
+ if children:
+ self.ln(1)
+ self.set_font("Helvetica", "I", 9)
+ self.set_text_color(80, 80, 80)
+ self.cell(0, 5, "Enfants:")
+ self.ln(5)
+ for c in children:
+ c_name = person_name(c)
+ c_birth, c_death = get_person_events(c)
+ c_dates = []
+ if c_birth:
+ c_dates.append(f"n. {c_birth}")
+ if c_death:
+ c_dates.append(f"d. {c_death}")
+ c_info = f" {c_name}"
+ if c_dates:
+ c_info += f" ({', '.join(c_dates)})"
+ self.bullet(c_info)
+ self.ln(3)
+
+
+# ── Build the report ─────────────────────────────────────────────────────────
+
+pdf = GenealogyPDF()
+pdf.alias_nb_pages()
+pdf.set_auto_page_break(auto=True, margin=20)
+pdf.add_page()
+
+# ── Cover ────────────────────────────────────────────────────────────────────
+pdf.ln(30)
+pdf.set_font("Helvetica", "B", 24)
+pdf.set_text_color(15, 23, 42)
+pdf.cell(0, 12, "Rapport de Genealogie", align="C")
+pdf.ln(10)
+pdf.set_font("Helvetica", "", 14)
+pdf.set_text_color(60, 60, 60)
+pdf.cell(0, 8, "Familles Keller, Staerle & Alliees", align="C")
+pdf.ln(8)
+pdf.set_font("Helvetica", "", 10)
+pdf.cell(0, 6, "Suisse (Schwytz) - Alsace (France) - Moselle", align="C")
+pdf.ln(6)
+pdf.cell(0, 6, f"Genere le {datetime.now().strftime('%d/%m/%Y a %H:%M')}", align="C")
+pdf.ln(20)
+
+# Stats box
+pdf.set_fill_color(240, 244, 248)
+pdf.set_draw_color(200, 210, 220)
+pdf.rect(30, pdf.get_y(), 150, 35, "DF")
+pdf.set_xy(35, pdf.get_y() + 3)
+pdf.set_font("Helvetica", "B", 10)
+pdf.set_text_color(15, 23, 42)
+pdf.cell(0, 5, "Resume")
+pdf.ln(6)
+pdf.set_x(35)
+pdf.set_font("Helvetica", "", 9)
+pdf.set_text_color(50, 50, 50)
+pdf.cell(0, 4.5, f"Personnes: {len(persons)} | Familles: {len(families)} | Lieux: {len(places)}")
+pdf.ln(4.5)
+pdf.set_x(35)
+pdf.cell(0, 4.5, f"Evenements: {len([o for o in objects if o.get('_class') == 'Event'])} | Medias: {len(media)}")
+pdf.ln(4.5)
+pdf.set_x(35)
+pdf.cell(0, 4.5, f"Generations: 7 (de 1760 a nos jours)")
+pdf.ln(4.5)
+pdf.set_x(35)
+pdf.cell(0, 4.5, f"Origines: Altendorf (SZ), Alsace, Moselle, Vaud")
+pdf.ln(15)
+
+# ── Table of Contents ───────────────────────────────────────────────────────
+pdf.add_page()
+pdf.section_title("Table des matieres")
+toc = [
+ "1. Arbre genealogique - Famille Keller",
+ "2. Arbre genealogique - Famille Staerle (branche alsacienne)",
+ "3. Branches alliees (Lemann, Kutzner, Hamm, Gangloff)",
+ "4. Fiches individuelles detaillees",
+ "5. Lieux",
+ "6. Medias et documents",
+ "7. Notes et sources",
+ "8. Statistiques",
+]
+for item in toc:
+ pdf.bullet(item)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 1. KELLER FAMILY TREE
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("1. Arbre genealogique - Famille Keller")
+
+# Gen 1: Jakob Keller
+pdf.sub_title("Generation 1 (ancetre)")
+jakob = by_handle.get("a37f8646-d374-40da-863b-86febf23b165")
+if jakob:
+ pdf.person_card(jakob)
+
+# Family F0006: Jakob -> Silvio
+fam_f6 = by_handle.get("103b3ad8ce8c43637db4d46f804c")
+if fam_f6:
+ pdf.family_section(fam_f6, "Famille")
+
+# Gen 2: Silvio + Margrit
+pdf.sub_title("Generation 2")
+silvio = by_handle.get("d1220b0f-9a04-4581-994a-6c4985f14c83")
+margrit = by_handle.get("bad07459-e2d9-4413-85bd-5b8b6e8463d5")
+if silvio:
+ pdf.person_card(silvio)
+if margrit:
+ pdf.person_card(margrit)
+
+fam_f1 = by_handle.get("103b34dfb7d317e6eacf93b9b322")
+if fam_f1:
+ pdf.family_section(fam_f1, "Famille")
+
+# Gen 3: Lucien + Sylvie
+pdf.sub_title("Generation 3")
+lucien = by_handle.get("4c572a11-13cd-46ee-9aee-39b904e86dcd")
+sylvie = by_handle.get("14d0669e-3174-4a94-b5e5-23af79331f20")
+if lucien:
+ pdf.person_card(lucien)
+if sylvie:
+ pdf.person_card(sylvie)
+
+fam_f0 = by_handle.get("103b34b3fc3a78115abdc520a0d7")
+if fam_f0:
+ pdf.family_section(fam_f0, "Famille")
+
+# Gen 4: Laurent + Lucie
+pdf.sub_title("Generation 4")
+laurent = by_handle.get("7c2b4663-ba21-49ce-af58-b268babe3e25")
+if laurent:
+ pdf.person_card(laurent)
+lucie = by_handle.get("7cb9d60e-8a61-47d5-a4bf-69d1a23a7068")
+if lucie:
+ pdf.person_card(lucie)
+
+# Gen 3 siblings: Tobias, Lea, Livia
+pdf.sub_title("Generation 3 - Freres et soeurs de Lucien")
+tobias = by_handle.get("1a501a5f-2721-491c-9bfe-7d3d1be88bdc")
+lea = by_handle.get("cfbe8ee0-0c31-4f92-ada8-a9054514df0c")
+livia = by_handle.get("b7c9c8c6-471d-481d-99c4-eb1753a0fb31")
+for p in [tobias, lea, livia]:
+ if p:
+ pdf.person_card(p)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 2. STAERLE FAMILY TREE (Alsace branch)
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("2. Arbre genealogique - Famille Staerle (Alsace)")
+
+# Gen 1 (1760): Johann Michael Staerle + Catharina Bernhard
+pdf.sub_title("Generation 1 (v. 1760)")
+jm1 = by_handle.get("1c80dd1f-77b4-4ab9-940c-e0776b6161ff")
+cmb = by_handle.get("cc5dad4f-1963-4037-b7db-040680011dd9")
+if jm1:
+ pdf.person_card(jm1)
+if cmb:
+ pdf.person_card(cmb)
+
+fam_f12 = by_handle.get("103b753f4b6772d2f9cd968bc788")
+if fam_f12:
+ pdf.family_section(fam_f12, "Famille")
+
+# Gen 2: Johann Michael Staerle + Christiana Metz
+pdf.sub_title("Generation 2")
+jm2 = by_handle.get("32c7f3f4-aa1c-4501-aa67-0d93ebcfbf68")
+cm = by_handle.get("01f5daba-73b0-4ae9-87cb-80afc755aca7")
+if jm2:
+ pdf.person_card(jm2)
+if cm:
+ pdf.person_card(cm)
+
+fam_f11 = by_handle.get("103b74f88457186cf98a9b6eca8a")
+if fam_f11:
+ pdf.family_section(fam_f11, "Famille")
+
+# Gen 3: Jean Adam Staerle + Madeleine Helf
+pdf.sub_title("Generation 3")
+ja = by_handle.get("30aeb3bd-dafd-4a4f-baf3-d75c1fd0d535")
+mh = by_handle.get("ab0e498f-3e4f-41de-8766-0941d58614b9")
+if ja:
+ pdf.person_card(ja)
+if mh:
+ pdf.person_card(mh)
+
+fam_f10 = by_handle.get("103b744d30a81442468b19dce1e6")
+if fam_f10:
+ pdf.family_section(fam_f10, "Famille")
+
+# Gen 4: Michel Staerle + Sophie Vogel
+pdf.sub_title("Generation 4")
+michel = by_handle.get("8d3313ce-d89c-473e-a7aa-4385d5bea769")
+sophie = by_handle.get("2c548840-a9ef-4f2c-9195-7c9007b963a2")
+if michel:
+ pdf.person_card(michel)
+if sophie:
+ pdf.person_card(sophie)
+
+fam_f9 = by_handle.get("103b730b5d1b5e3c15d9fa4d5111")
+if fam_f9:
+ pdf.family_section(fam_f9, "Famille")
+
+# Gen 5: Guillaume Staerle + Frida Bender
+pdf.sub_title("Generation 5")
+guillaume1 = by_handle.get("3c98357d-3645-4a74-ae8d-3fb91b005fe8")
+frida = by_handle.get("ce24fa7a-b67d-47ba-ad46-3fc940be0c25")
+if guillaume1:
+ pdf.person_card(guillaume1)
+if frida:
+ pdf.person_card(frida)
+
+fam_f3 = by_handle.get("103b39893eb03c1e4ed40f6f8d9b")
+if fam_f3:
+ pdf.family_section(fam_f3, "Famille")
+
+# Gen 6: Guillaume Staerle + Catherine Jund
+pdf.sub_title("Generation 6")
+guillaume2 = by_handle.get("bce9436e-8a1c-4eb1-b4c6-bfdcc7c6a935")
+catherine = by_handle.get("88c262e3-9d6f-4f29-a368-03732e5d4b7b")
+if guillaume2:
+ pdf.person_card(guillaume2)
+if catherine:
+ pdf.person_card(catherine)
+
+fam_f2 = by_handle.get("103b35aa6e2918ae0dc0317e052b")
+if fam_f2:
+ pdf.family_section(fam_f2, "Famille")
+
+# Gen 7: Sylvie, Annette, Corinne, Isabelle, Anne
+pdf.sub_title("Generation 7 - Enfants Staerle")
+staerle_children = [
+ "14d0669e-3174-4a94-b5e5-23af79331f20", # Sylvie
+ "e30f96b7-2860-4473-acd5-f5d9b5ae4f36", # Annette
+ "ccf375b4-c0ca-4bba-87e6-365c6b348bb5", # Corinne
+ "9428a2d4-6330-4a1c-835e-f92eacefb805", # Isabelle
+ "f6d9662e-02a4-4327-99b8-9b48a30f8bc9", # Anne
+]
+for h in staerle_children:
+ p = by_handle.get(h)
+ if p:
+ pdf.person_card(p)
+
+# Christian Staerle + Yvette
+pdf.sub_title("Generation 7 - Christian Staerle & Yvette")
+christian = by_handle.get("33c893ff-60b3-4420-931a-bb3f28ce0562")
+yvette = by_handle.get("66c36421-4e32-42e9-bc57-c47f6e6adfe4")
+if christian:
+ pdf.person_card(christian)
+if yvette:
+ pdf.person_card(yvette)
+
+fam_f5 = by_handle.get("103b3ac22c3c4c35c31f77a70910")
+if fam_f5:
+ pdf.family_section(fam_f5, "Famille")
+
+# Gen 8: Chris, Jean-michel Staerle
+pdf.sub_title("Generation 8 - Enfants Christian & Yvette")
+chris = by_handle.get("4e908cd1-83e6-4aa5-a004-4d510144a528")
+jm_staerle = by_handle.get("ed8e0c2d-49e2-43a7-9c3f-18bd72afbdc7")
+for p in [chris, jm_staerle]:
+ if p:
+ pdf.person_card(p)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 3. ALLIED BRANCHES
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("3. Branches alliees")
+
+# Annette Hamm + Daniel Gangloff
+pdf.sub_title("Famille Hamm-Gangloff")
+annette = by_handle.get("e30f96b7-2860-4473-acd5-f5d9b5ae4f36")
+daniel = by_handle.get("f6a18cb6-d08c-44b1-b52f-53de36d24107")
+if annette:
+ pdf.person_card(annette)
+if daniel:
+ pdf.person_card(daniel)
+
+fam_f4 = by_handle.get("103b3aa996d7492b0a90e358032a")
+if fam_f4:
+ pdf.family_section(fam_f4, "Famille")
+
+# Lea Keller + Sandro Lemann
+pdf.sub_title("Famille Lemann")
+lea = by_handle.get("cfbe8ee0-0c31-4f92-ada8-a9054514df0c")
+sandro = by_handle.get("ef9b14d5-ab40-48fd-a110-eb4aac2a6af2")
+if lea:
+ pdf.person_card(lea)
+if sandro:
+ pdf.person_card(sandro)
+
+fam_f7 = by_handle.get("103b3b4a63723ca20a2d8c369711")
+if fam_f7:
+ pdf.family_section(fam_f7, "Famille")
+
+# Livia Keller + Florian Kutzner
+pdf.sub_title("Famille Kutzner")
+livia = by_handle.get("b7c9c8c6-471d-481d-99c4-eb1753a0fb31")
+florian = by_handle.get("b4984dbd-2f15-4a41-9e6f-900d0f67e507")
+if livia:
+ pdf.person_card(livia)
+if florian:
+ pdf.person_card(florian)
+
+fam_f8 = by_handle.get("103b3b5df8a1443d8513129fe995")
+if fam_f8:
+ pdf.family_section(fam_f8, "Famille")
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 4. DETAILED PERSON CARDS
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("4. Fiches individuelles detaillees")
+
+all_persons_sorted = sorted(persons, key=lambda p: p.get("gramps_id", ""))
+for p in all_persons_sorted:
+ name = person_name(p)
+ pid = p.get("gramps_id", "")
+ birth, death = get_person_events(p)
+ gender_map = {0: "Femme", 1: "Homme", 2: "Autre"}
+ gender = gender_map.get(p.get("gender"), "?")
+
+ if pdf.get_y() > 230:
+ pdf.add_page()
+
+ pdf.sub_title(f"{name} ({pid})")
+ pdf.kv("Nom:", name)
+ pdf.kv("Genre:", gender)
+ if birth:
+ pdf.kv("Naissance:", birth)
+ if death:
+ pdf.kv("Deces:", death)
+
+ father, mother = get_parents(p)
+ if father or mother:
+ parents_str = []
+ if father:
+ parents_str.append(person_name(father))
+ if mother:
+ parents_str.append(person_name(mother))
+ pdf.kv("Parents:", " & ".join(parents_str))
+
+ own_fams = get_families(p)
+ if own_fams:
+ for f in own_fams:
+ spouse_handle = f.get("father_handle") if f.get("mother_handle") == p["handle"] else f.get("mother_handle")
+ spouse = by_handle.get(spouse_handle) if spouse_handle else None
+ children = get_children(f)
+ if spouse:
+ pdf.kv("Conjoint(e):", person_name(spouse))
+ if children:
+ pdf.kv("Enfants:", ", ".join(person_name(c) for c in children))
+
+ events = get_events_for_person(p)
+ for ev in events:
+ txt = ev["type"]
+ if ev["date"]:
+ txt += f" le {ev['date']}"
+ if ev["place"]:
+ place_name = get_place_name(ev["place"])
+ if place_name:
+ txt += f" - {place_name}"
+ if ev["desc"]:
+ txt += f" ({ev['desc']})"
+ pdf.kv(ev["type"] + ":", txt if txt != ev["type"] else "-")
+
+ p_notes = get_notes_for_person(p)
+ for n in p_notes:
+ pdf.kv("Note:", n.strip())
+
+ pdf.ln(3)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 5. PLACES
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("5. Lieux")
+for pl in places:
+ name = pl.get("name", {}).get("value", "")
+ lat = pl.get("lat", "")
+ lon = pl.get("long", "")
+ ptype = pl.get("place_type", {}).get("string", "")
+ pid = pl.get("gramps_id", "")
+
+ if pdf.get_y() > 250:
+ pdf.add_page()
+
+ pdf.sub_title(f"{name} ({pid})")
+ if ptype:
+ pdf.kv("Type:", ptype)
+ if lat and lon:
+ pdf.kv("Coordonnees:", f"{lat}, {lon}")
+
+ for pr in pl.get("placeref_list", []):
+ parent = by_handle.get(pr.get("ref"))
+ if parent:
+ pname = parent.get("name", {}).get("value", "")
+ pdf.kv("Fait partie de:", pname)
+ pdf.ln(2)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 6. MEDIA
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("6. Medias et documents")
+for m in media:
+ desc = m.get("desc", "")
+ mime = m.get("mime", "")
+ path = m.get("path", "")
+ mid = m.get("gramps_id", "")
+ date_str = format_date(m.get("date", {}))
+
+ pdf.sub_title(f"{desc} ({mid})")
+ pdf.kv("Type:", mime)
+ pdf.kv("Fichier:", path)
+ if date_str:
+ pdf.kv("Date:", date_str)
+ pdf.ln(2)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 7. NOTES
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("7. Notes et sources")
+for n in notes:
+ text = n.get("text", {}).get("string", "")
+ ntype = n.get("type", {}).get("string", "")
+ nid = n.get("gramps_id", "")
+
+ if pdf.get_y() > 260:
+ pdf.add_page()
+
+ pdf.sub_title(f"Note {nid}")
+ if ntype:
+ pdf.kv("Type:", ntype)
+ pdf.body_text(text.strip())
+ pdf.ln(2)
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 8. STATISTICS
+# ═══════════════════════════════════════════════════════════════════════════
+pdf.add_page()
+pdf.section_title("8. Statistiques")
+
+males = sum(1 for p in persons if p.get("gender") == 1)
+females = sum(1 for p in persons if p.get("gender") == 0)
+other = sum(1 for p in persons if p.get("gender") == 2)
+
+pdf.sub_title("Repartition par genre")
+pdf.kv("Hommes:", str(males))
+pdf.kv("Femmes:", str(females))
+pdf.kv("Autre/Inconnu:", str(other))
+pdf.ln(3)
+
+married = sum(1 for f in families if f.get("type", {}).get("value") == 0)
+unmarried = sum(1 for f in families if f.get("type", {}).get("value") == 3)
+pdf.sub_title("Familles")
+pdf.kv("Mariees:", str(married))
+pdf.kv("Non mariees:", str(unmarried))
+pdf.ln(3)
+
+pdf.sub_title("Evenements")
+event_counts = defaultdict(int)
+for ev in [o for o in objects if o.get("_class") == "Event"]:
+ et = event_type_str(ev.get("type", {}))
+ event_counts[et] += 1
+for et, count in sorted(event_counts.items(), key=lambda x: -x[1]):
+ pdf.kv(et + ":", str(count))
+pdf.ln(3)
+
+pdf.sub_title("Lieux")
+pdf.kv("Total lieux:", str(len(places)))
+for pl in places:
+ name = pl.get("name", {}).get("value", "")
+ ptype = pl.get("place_type", {}).get("string", "")
+ pdf.bullet(f"{name} ({ptype})" if ptype else name)
+
+# ── Save ────────────────────────────────────────────────────────────────────
+OUTPUT = "/root/workspace/rapport-genealogie-keller-staerle.pdf"
+pdf.output(OUTPUT)
+print(f"PDF genere: {OUTPUT}")
+print(f"Pages: {pdf.page_no()}")
+print(f"Personnes: {len(persons)}, Familles: {len(families)}, Lieux: {len(places)}, Evenements: {len([o for o in objects if o.get('_class') == 'Event'])}")
diff --git a/one-shot/google-workspace-mcp-login.py b/one-shot/google-workspace-mcp-login.py
new file mode 100644
index 0000000..f282895
--- /dev/null
+++ b/one-shot/google-workspace-mcp-login.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""
+OAuth login for Google Workspace MCP servers (Sheets, Drive) in non-TTY.
+
+Usage:
+ 1. systemctl --user stop hermes-gateway.service
+ 2. python3 /root/workspace/google-workspace-mcp-login.py
+ 3. For each server, open the URL in your browser, authorize, paste callback URL
+ 4. systemctl --user start hermes-gateway.service
+"""
+import sys
+import os
+import json
+import hashlib
+import base64
+import secrets
+import string
+import asyncio
+from urllib.parse import urlencode, urlparse, parse_qs
+
+sys.path.insert(0, "/usr/local/lib/hermes-agent")
+sys.path.insert(0, "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages")
+
+from hermes_cli.config import load_config
+from tools.mcp_oauth import HermesTokenStorage, _configure_callback_port
+from tools.mcp_oauth_manager import get_manager
+
+import httpx
+
+# ── Google OAuth settings ──────────────────────────────────────────────
+CLIENT_ID = "1090672727072-4buk3522j52pqut02m22rcnto31938jl.apps.googleusercontent.com"
+CLIENT_SECRET = "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"
+AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
+TOKEN_URI = "https://oauth2.googleapis.com/token"
+
+# Servers to configure
+SERVERS = [
+ {
+ "name": "google-sheets",
+ "scopes": [
+ "https://www.googleapis.com/auth/spreadsheets",
+ "https://www.googleapis.com/auth/drive.file",
+ ],
+ },
+ {
+ "name": "google-drive",
+ "scopes": [
+ "https://www.googleapis.com/auth/drive.readonly",
+ "https://www.googleapis.com/auth/drive.file",
+ ],
+ },
+]
+# ────────────────────────────────────────────────────────────────────────
+
+
+async def exchange_code(
+ server_name: str,
+ code: str,
+ code_verifier: str,
+ redirect_uri: str,
+) -> bool:
+ """Exchange authorization code for tokens and save them."""
+ from mcp.shared.auth import OAuthToken, OAuthClientInformationFull
+
+ storage = HermesTokenStorage(server_name)
+
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET,
+ "code_verifier": code_verifier,
+ }
+
+ resp = await client.post(TOKEN_URI, data=data, headers={"Accept": "application/json"})
+
+ if resp.status_code != 200:
+ print(f" ✗ Token exchange failed: HTTP {resp.status_code}")
+ print(f" Response: {resp.text[:500]}")
+ return False
+
+ token_data = resp.json()
+ print(f" ✓ Token received!")
+
+ token = OAuthToken(
+ access_token=token_data.get("access_token", ""),
+ token_type=token_data.get("token_type", "Bearer"),
+ expires_in=token_data.get("expires_in"),
+ refresh_token=token_data.get("refresh_token"),
+ scope=token_data.get("scope"),
+ )
+ await storage.set_tokens(token)
+
+ # Write complete client info
+ client_info = OAuthClientInformationFull(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_SECRET,
+ redirect_uris=[redirect_uri],
+ grant_types=["authorization_code", "refresh_token"],
+ response_types=["code"],
+ token_endpoint_auth_method="client_secret_post",
+ )
+ await storage.set_client_info(client_info)
+
+ print(f" ✓ Tokens saved to ~/.hermes/mcp-tokens/{server_name}.json")
+ return True
+
+
+def main():
+ for server in SERVERS:
+ name = server["name"]
+ scopes = server["scopes"]
+
+ print(f"\n{'=' * 70}")
+ print(f" Server: {name}")
+ print(f" Scopes: {' '.join(scopes)}")
+ print(f"{'=' * 70}")
+
+ # Clear old tokens
+ storage = HermesTokenStorage(name)
+ for p in [storage._tokens_path(), storage._client_info_path(), storage._meta_path()]:
+ if p.exists():
+ p.unlink()
+ get_manager().remove(name)
+
+ # Configure callback port
+ cfg = {}
+ _configure_callback_port(cfg, storage)
+ resolved_port = cfg.get("_resolved_port", 38255)
+ redirect_uri = f"http://127.0.0.1:{resolved_port}/callback"
+
+ # Generate PKCE
+ code_verifier = "".join(
+ secrets.choice(string.ascii_letters + string.digits + "-._~")
+ for _ in range(128)
+ )
+ digest = hashlib.sha256(code_verifier.encode()).digest()
+ code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
+ state = secrets.token_urlsafe(32)
+
+ # Build auth URL
+ params = {
+ "response_type": "code",
+ "client_id": CLIENT_ID,
+ "redirect_uri": redirect_uri,
+ "scope": " ".join(scopes),
+ "state": state,
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "access_type": "offline",
+ "prompt": "consent",
+ }
+ auth_url = f"{AUTH_URI}?{urlencode(params)}"
+
+ print(f"\n OPEN THIS URL IN YOUR BROWSER:\n")
+ print(f" {auth_url}")
+ print(f"\n After authorizing, paste the FULL callback URL and press Enter:")
+ print(f" (or type 'skip' to skip this server)")
+
+ # Read callback URL from stdin
+ try:
+ callback_url = sys.stdin.readline().strip()
+ except (EOFError, KeyboardInterrupt):
+ print("\n Skipping.")
+ continue
+
+ if not callback_url or callback_url.lower() in ("skip", "s", "q", "quit"):
+ print(" Skipped.")
+ continue
+
+ # Parse callback
+ parsed = urlparse(callback_url)
+ cb_params = parse_qs(parsed.query)
+ received_code = cb_params.get("code", [None])[0]
+ received_state = cb_params.get("state", [None])[0]
+
+ if not received_code:
+ print(" ✗ No 'code' parameter in callback URL")
+ continue
+
+ if received_state != state:
+ print(f" ✗ State mismatch! Expected {state}, got {received_state}")
+ continue
+
+ print(f" ✓ Callback verified")
+
+ # Exchange code
+ success = asyncio.run(
+ exchange_code(name, received_code, code_verifier, redirect_uri)
+ )
+
+ if success:
+ print(f" ✓ '{name}' authenticated successfully!")
+ else:
+ print(f" ✗ '{name}' authentication failed")
+
+ print(f"\n{'=' * 70}")
+ print(f" Done. Restart the gateway:")
+ print(f" systemctl --user restart hermes-gateway.service")
+ print(f"{'=' * 70}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/one-shot/oauth-step1.py b/one-shot/oauth-step1.py
new file mode 100644
index 0000000..023f000
--- /dev/null
+++ b/one-shot/oauth-step1.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Step 1: Register OAuth client and generate authorization URL."""
+import sys, os, json, hashlib, base64, secrets, string, asyncio
+from urllib.parse import urlencode, urlparse
+
+sys.path.insert(0, "/usr/local/lib/hermes-agent")
+
+SERVER_NAME = sys.argv[1] if len(sys.argv) > 1 else "cloudflare"
+
+from hermes_cli.config import load_config
+config = load_config()
+servers = config.get("mcp_servers", {})
+server_config = servers.get(SERVER_NAME, {})
+url = server_config.get("url", "")
+oauth_config = server_config.get("oauth", {})
+
+from tools.mcp_oauth import HermesTokenStorage, _configure_callback_port
+from tools.mcp_oauth_manager import get_manager
+
+# Clear old tokens
+storage = HermesTokenStorage(SERVER_NAME)
+for p in [storage._tokens_path(), storage._client_info_path(), storage._meta_path()]:
+ if p.exists():
+ p.unlink()
+ print(f" ✓ Removed {p.name}")
+get_manager().remove(SERVER_NAME)
+
+cfg = dict(oauth_config or {})
+_configure_callback_port(cfg, storage)
+resolved_port = cfg.get("_resolved_port", 38255)
+redirect_uri = f"http://127.0.0.1:{resolved_port}/callback"
+
+# Generate PKCE
+code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
+digest = hashlib.sha256(code_verifier.encode()).digest()
+code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
+state = secrets.token_urlsafe(32)
+
+# Register client at the correct OAuth domain
+parsed_url = urlparse(url)
+oauth_domain = f"{parsed_url.scheme}://{parsed_url.netloc}"
+
+import httpx
+
+async def register():
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ reg_payload = {
+ "client_name": "Hermes Agent",
+ "redirect_uris": [redirect_uri],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ }
+ resp = await client.post(
+ f"{oauth_domain}/register",
+ json=reg_payload,
+ headers={"Accept": "application/json"}
+ )
+ if resp.status_code not in (200, 201):
+ print(f" ✗ Registration failed: HTTP {resp.status_code}")
+ print(f" Response: {resp.text[:500]}")
+ return None, None
+
+ reg_data = resp.json()
+ client_id = reg_data.get("client_id")
+
+ # Discover token endpoint
+ token_endpoint = None
+ for disc_url in [
+ f"{oauth_domain}/.well-known/oauth-authorization-server",
+ f"{oauth_domain}/.well-known/openid-configuration",
+ ]:
+ try:
+ resp2 = await client.get(disc_url, headers={"Accept": "application/json"})
+ if resp2.status_code == 200:
+ data = resp2.json()
+ token_endpoint = data.get("token_endpoint")
+ if token_endpoint:
+ break
+ except:
+ pass
+
+ if not token_endpoint:
+ token_endpoint = f"{oauth_domain}/token"
+
+ print(f" ✓ Registered client: {client_id}")
+ print(f" Token endpoint: {token_endpoint}")
+ return client_id, token_endpoint
+
+client_id, token_endpoint = asyncio.run(register())
+if not client_id:
+ sys.exit(1)
+
+# Save state for step 2
+state_data = {
+ "server_name": SERVER_NAME,
+ "client_id": client_id,
+ "token_endpoint": token_endpoint,
+ "redirect_uri": redirect_uri,
+ "code_verifier": code_verifier,
+ "state": state,
+ "url": url,
+}
+with open(f"/tmp/mcp_oauth_{SERVER_NAME}.json", "w") as f:
+ json.dump(state_data, f)
+
+# Build auth URL using discovered metadata
+params = {
+ "response_type": "code",
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ "state": state,
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "resource": url,
+}
+
+# Discover the correct authorization endpoint
+import httpx
+
+async def discover_auth_endpoint():
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ for disc_url in [
+ f"{oauth_domain}/.well-known/oauth-authorization-server",
+ ]:
+ try:
+ resp = await client.get(disc_url, headers={"Accept": "application/json"})
+ if resp.status_code == 200:
+ data = resp.json()
+ auth_ep = data.get("authorization_endpoint")
+ if auth_ep:
+ return auth_ep
+ except:
+ pass
+ return f"{oauth_domain}/authorize"
+
+auth_endpoint = asyncio.run(discover_auth_endpoint())
+print(f" Auth endpoint: {auth_endpoint}")
+
+auth_url = f"{auth_endpoint}?{urlencode(params)}"
+
+print(f"\n {'='*70}")
+print(f" Server: {SERVER_NAME}")
+print(f" URL: {url}")
+print(f" OAuth domain: {oauth_domain}")
+print(f" {'='*70}")
+print(f"\n OPEN THIS URL IN YOUR BROWSER:\n")
+print(f" {auth_url}")
+print(f"\n {'='*70}")
+print(f" Then run: python3 /root/workspace/oauth-step2.py {SERVER_NAME} ")
+print(f" {'='*70}")
diff --git a/one-shot/oauth-step2.py b/one-shot/oauth-step2.py
new file mode 100644
index 0000000..068d54f
--- /dev/null
+++ b/one-shot/oauth-step2.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""Step 2: Exchange authorization code for tokens using saved state."""
+import sys, os, json, asyncio
+
+sys.path.insert(0, "/usr/local/lib/hermes-agent")
+
+SERVER_NAME = sys.argv[1] if len(sys.argv) > 1 else "cloudflare"
+CALLBACK_URL = sys.argv[2] if len(sys.argv) > 2 else ""
+
+if not CALLBACK_URL:
+ print("Usage: python3 oauth-step2.py ")
+ sys.exit(1)
+
+# Load state from step 1
+state_path = f"/tmp/mcp_oauth_{SERVER_NAME}.json"
+if not os.path.exists(state_path):
+ print(f" ✗ State file not found. Run oauth-step1.py first.")
+ sys.exit(1)
+
+with open(state_path) as f:
+ state_data = json.load(f)
+
+from urllib.parse import urlparse, parse_qs
+parsed = urlparse(CALLBACK_URL)
+cb_params = parse_qs(parsed.query)
+received_code = cb_params.get("code", [None])[0]
+received_state = cb_params.get("state", [None])[0]
+
+if not received_code:
+ print(" ✗ No 'code' parameter in callback URL")
+ sys.exit(1)
+
+if received_state != state_data["state"]:
+ print(f" ✗ State mismatch! Expected {state_data['state']}, got {received_state}")
+ sys.exit(1)
+
+print(f" ✓ Callback verified (state matches)")
+print(f" Code: {received_code[:40]}...")
+
+from tools.mcp_oauth import HermesTokenStorage
+
+storage = HermesTokenStorage(SERVER_NAME)
+client_id = state_data["client_id"]
+token_endpoint = state_data["token_endpoint"]
+redirect_uri = state_data["redirect_uri"]
+code_verifier = state_data["code_verifier"]
+
+import httpx
+
+async def exchange():
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ data = {
+ "grant_type": "authorization_code",
+ "code": received_code,
+ "redirect_uri": redirect_uri,
+ "client_id": client_id,
+ "code_verifier": code_verifier,
+ }
+
+ resp = await client.post(token_endpoint, data=data, headers={"Accept": "application/json"})
+
+ if resp.status_code != 200:
+ print(f" ✗ Token exchange failed: HTTP {resp.status_code}")
+ print(f" Response: {resp.text[:500]}")
+ return False
+
+ token_data = resp.json()
+ print(f" ✓ Token received!")
+
+ from mcp.shared.auth import OAuthToken, OAuthClientInformationFull
+ token = OAuthToken(
+ access_token=token_data.get("access_token", ""),
+ token_type=token_data.get("token_type", "Bearer"),
+ expires_in=token_data.get("expires_in"),
+ refresh_token=token_data.get("refresh_token"),
+ scope=token_data.get("scope"),
+ )
+ await storage.set_tokens(token)
+
+ client_info = OAuthClientInformationFull(
+ client_id=client_id,
+ redirect_uris=[redirect_uri],
+ )
+ await storage.set_client_info(client_info)
+
+ print(f" ✓ Tokens saved to ~/.hermes/mcp-tokens/{SERVER_NAME}.json")
+ return True
+
+success = asyncio.run(exchange())
+
+if success:
+ print(f"\n ✓ '{SERVER_NAME}' authenticated successfully!")
+ # Clean up state file
+ os.remove(state_path)
+else:
+ print(f"\n ✗ '{SERVER_NAME}' authentication failed")
+ sys.exit(1)
diff --git a/one-shot/plan_maison.py b/one-shot/plan_maison.py
new file mode 100644
index 0000000..e571951
--- /dev/null
+++ b/one-shot/plan_maison.py
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+"""Plan d'étage — murs en traits épais, ouvertures nettes."""
+
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+from matplotlib.patches import FancyBboxPatch, Arc, Circle, Rectangle
+import numpy as np
+
+fig, ax = plt.subplots(1, 1, figsize=(26, 20))
+ax.set_aspect('equal')
+ax.axis('off')
+
+# ── Palette ────────────────────────────────────────────────────
+MUR = '#3a3a5c'
+MUR_EXT = '#1a1a2e'
+PIECE = {
+ 'salon': '#fff8e7', 'cuisine': '#eef5e8', 'chambre': '#eaf0f8',
+ 'sdb': '#e0ecf2', 'wc': '#f5ecec', 'bureau': '#f0f0f5',
+ 'it': '#e8e8f0', 'couloir': '#f5f3ee',
+}
+MEUBLE = '#8b7355'
+MEUBLE_IT = '#4a4a6a'
+PORTE_C = '#8b4513'
+FENETRE_C = '#7ec8e3'
+COTE = '#888888'
+TEXTE = '#1a1a2e'
+LW = 8.0 # linewidth pour murs (épais, bien visible)
+LW_EXT = 10.0 # murs extérieurs plus épais
+
+# ═══════════════════════════════════════════════════════════════
+# LAYOUT
+# ═══════════════════════════════════════════════════════════════
+# Bas (y=0..8) : Salon(0-8) | Cuisine(8-13) | SalleIT(13-15.5) | Ch1(15.5-20)
+# Couloir (8..9.5)
+# Haut (9.5..15) : SDB(0-4) | WC(4-6) | Ch2(6-11) | Ch3(11-16) | Bureau(16-20)
+
+# ── Fond des pièces ────────────────────────────────────────────
+for (x, y, w, h, c) in [
+ (0, 0, 8, 8, PIECE['salon']), (8, 0, 5, 8, PIECE['cuisine']),
+ (13, 0, 2.5, 8, PIECE['it']), (15.5, 0, 4.5, 8, PIECE['chambre']),
+ (0, 8, 20, 1.5, PIECE['couloir']),
+ (0, 9.5, 4, 5.5, PIECE['sdb']), (4, 9.5, 2, 5.5, PIECE['wc']),
+ (6, 9.5, 5, 5.5, PIECE['chambre']), (11, 9.5, 5, 5.5, PIECE['chambre']),
+ (16, 9.5, 4, 5.5, PIECE['bureau']),
+]:
+ ax.add_patch(Rectangle((x, y), w, h, facecolor=c, edgecolor='none', zorder=1))
+
+# ── Helpers ────────────────────────────────────────────────────
+
+def mur_ligne(x1, y1, x2, y2, lw=LW):
+ """Mur en trait épais avec bouts plats (solid_capstyle='butt')."""
+ ax.plot([x1, x2], [y1, y2], color=MUR, linewidth=lw,
+ solid_capstyle='butt', zorder=3)
+
+def mur_ext_ligne(x1, y1, x2, y2):
+ """Mur extérieur en trait très épais."""
+ ax.plot([x1, x2], [y1, y2], color=MUR_EXT, linewidth=LW_EXT,
+ solid_capstyle='butt', zorder=3)
+
+def porte_h(x, y, largeur=0.9):
+ """Porte dans un mur horizontal — battant vers le haut."""
+ ax.plot([x, x], [y, y+largeur], color=PORTE_C, linewidth=2.5, zorder=5)
+ arc = Arc((x, y), largeur*2, largeur*2, angle=90, theta1=0, theta2=90,
+ color=PORTE_C, linewidth=1.2, linestyle='--', zorder=4)
+ ax.add_patch(arc)
+ ax.add_patch(Circle((x, y), 0.06, facecolor=PORTE_C, edgecolor='none', zorder=5))
+
+def porte_v(x, y, largeur=0.9):
+ """Porte dans un mur vertical — battant vers la droite."""
+ ax.plot([x, x+largeur], [y, y], color=PORTE_C, linewidth=2.5, zorder=5)
+ arc = Arc((x, y), largeur*2, largeur*2, angle=0, theta1=0, theta2=90,
+ color=PORTE_C, linewidth=1.2, linestyle='--', zorder=4)
+ ax.add_patch(arc)
+ ax.add_patch(Circle((x, y), 0.06, facecolor=PORTE_C, edgecolor='none', zorder=5))
+
+def fenetre_h(x1, x2, y):
+ """Fenêtre dans un mur horizontal extérieur."""
+ # Petits traits de mur aux extrémités
+ for x in [x1, x2]:
+ ax.plot([x, x], [y-0.15, y+0.15], color=MUR_EXT, linewidth=2, zorder=4)
+ ax.plot([x1, x2], [y, y], color=FENETRE_C, linewidth=4, solid_capstyle='butt', zorder=4)
+
+def fenetre_v(y1, y2, x):
+ """Fenêtre dans un mur vertical extérieur."""
+ for y in [y1, y2]:
+ ax.plot([x-0.15, x+0.15], [y, y], color=MUR_EXT, linewidth=2, zorder=4)
+ ax.plot([x, x], [y1, y2], color=FENETRE_C, linewidth=4, solid_capstyle='butt', zorder=4)
+
+def meuble(x, y, w, h, label='', couleur=MEUBLE, txt_color='white'):
+ ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.04",
+ facecolor=couleur, edgecolor='#3d2e1a', linewidth=0.8, alpha=0.75, zorder=6))
+ if label:
+ ax.text(x+w/2, y+h/2, label, ha='center', va='center',
+ fontsize=6.5, color=txt_color, fontweight='bold', zorder=7)
+
+def label_piece(x, y, nom, surface, sz_nom=11, sz_surf=8):
+ ax.text(x, y+0.35, nom, ha='center', va='center', fontsize=sz_nom,
+ color=TEXTE, fontweight='bold', zorder=8)
+ ax.text(x, y-0.35, surface, ha='center', va='center', fontsize=sz_surf, color=COTE, zorder=8)
+
+def cote(x1, y1, x2, y2, label, decal=0.5):
+ ax.plot([x1, x2], [y1, y2], color=COTE, linewidth=0.7, zorder=2)
+ dx, dy = x2-x1, y2-y1
+ L = np.sqrt(dx*dx+dy*dy)
+ if L > 0:
+ nx, ny = -dy/L*0.12, dx/L*0.12
+ ax.plot([x1+nx, x1-nx], [y1+ny, y1-ny], color=COTE, linewidth=0.7, zorder=2)
+ ax.plot([x2+nx, x2-nx], [y2+ny, y2-ny], color=COTE, linewidth=0.7, zorder=2)
+ mx, my = (x1+x2)/2, (y1+y2)/2
+ px, py = (-dy/L*decal, dx/L*decal) if L > 0 else (0, decal)
+ ax.text(mx+px, my+py, label, ha='center', va='center', fontsize=7.5, color=COTE, zorder=2)
+
+# ═══════════════════════════════════════════════════════════════
+# MURS EXTÉRIEURS (traits épais, segments avec gaps)
+# ═══════════════════════════════════════════════════════════════
+
+# Bas (y=0)
+mur_ext_ligne(0, 0, 2, 0) # coin → fenêtre salon
+mur_ext_ligne(6, 0, 9.1, 0) # fenêtre salon → porte entrée
+mur_ext_ligne(10, 0, 13, 0) # porte entrée → fenêtre cuisine
+mur_ext_ligne(13, 0, 14, 0) # → fenêtre IT
+mur_ext_ligne(15, 0, 17, 0) # fenêtre IT → fenêtre ch1
+mur_ext_ligne(19, 0, 20, 0) # fenêtre ch1 → coin
+
+# Droite (x=20)
+mur_ext_ligne(20, 0, 20, 2)
+mur_ext_ligne(20, 6, 20, 11)
+mur_ext_ligne(20, 13.5, 20, 15)
+
+# Haut (y=15)
+mur_ext_ligne(0, 15, 5, 15)
+mur_ext_ligne(5.5, 15, 8, 15)
+mur_ext_ligne(10, 15, 13, 15)
+mur_ext_ligne(15, 15, 20, 15)
+
+# Gauche (x=0)
+mur_ext_ligne(0, 0, 0, 2)
+mur_ext_ligne(0, 5, 0, 11)
+mur_ext_ligne(0, 13.5, 0, 15)
+
+# ═══════════════════════════════════════════════════════════════
+# MURS INTÉRIEURS (traits épais, segments avec gaps pour portes)
+# ═══════════════════════════════════════════════════════════════
+
+# Salon | Cuisine (x=8)
+mur_ligne(8, 0, 8, 8)
+
+# Cuisine | Salle IT (x=13)
+mur_ligne(13, 0, 13, 8)
+
+# Salle IT | Chambre 1 (x=15.5)
+mur_ligne(15.5, 0, 15.5, 8)
+
+# Couloir BAS (y=8) — segments avec gaps pour portes
+mur_ligne(0, 8, 3.05, 8) # gauche → porte salon
+mur_ligne(3.95, 8, 9.55, 8) # porte salon → porte cuisine
+mur_ligne(10.45, 8, 13.55, 8) # porte cuisine → porte IT
+mur_ligne(14.45, 8, 17.05, 8) # porte IT → porte ch1
+mur_ligne(17.95, 8, 20, 8) # porte ch1 → droite
+
+# Couloir HAUT (y=9.5) — segments avec gaps pour portes
+mur_ligne(0, 9.5, 1.55, 9.5) # gauche → porte SDB
+mur_ligne(2.45, 9.5, 4.55, 9.5) # porte SDB → porte WC
+mur_ligne(5.45, 9.5, 8.05, 9.5) # porte WC → porte ch2
+mur_ligne(8.95, 9.5, 13.05, 9.5) # porte ch2 → porte ch3
+mur_ligne(13.95, 9.5, 17.55, 9.5) # porte ch3 → porte bureau
+mur_ligne(18.45, 9.5, 20, 9.5) # porte bureau → droite
+
+# Murs verticaux partie haute
+mur_ligne(4, 9.5, 4, 15) # SDB | WC
+mur_ligne(6, 9.5, 6, 15) # WC | Ch2
+mur_ligne(11, 9.5, 11, 15) # Ch2 | Ch3
+mur_ligne(16, 9.5, 16, 15) # Ch3 | Bureau
+
+# ═══════════════════════════════════════════════════════════════
+# PORTES
+# ═══════════════════════════════════════════════════════════════
+
+# Entrée (mur du bas)
+porte_v(9.55, 0)
+
+# Couloir bas → pièces
+porte_h(3.5, 8) # salon
+porte_h(10, 8) # cuisine
+porte_h(14, 8) # salle IT
+porte_h(17.5, 8) # chambre 1
+
+# Couloir haut → pièces
+porte_h(2, 9.5) # SDB
+porte_h(5, 9.5) # WC
+porte_h(8.5, 9.5) # chambre 2
+porte_h(13.5, 9.5) # chambre 3
+porte_h(18, 9.5) # bureau
+
+# ═══════════════════════════════════════════════════════════════
+# FENÊTRES
+# ═══════════════════════════════════════════════════════════════
+fenetre_h(2, 6, 0) # salon — baie vitrée
+fenetre_v(2, 5, 0) # salon — côté gauche
+fenetre_h(10, 13, 0) # cuisine
+fenetre_h(14, 15, 0) # salle IT
+fenetre_h(17, 19, 0) # chambre 1
+fenetre_v(2, 6, 20) # chambre 1 — côté droit
+fenetre_v(11, 13.5, 0) # SDB
+fenetre_h(5, 5.5, 15) # WC — haut
+fenetre_h(8, 10, 15) # chambre 2 — haut
+fenetre_h(13, 15, 15) # chambre 3 — haut
+fenetre_v(11, 13.5, 20) # bureau — côté droit
+
+# ═══════════════════════════════════════════════════════════════
+# MEUBLES
+# ═══════════════════════════════════════════════════════════════
+
+# Salon
+meuble(1, 1.5, 3, 1, 'Canapé d\'angle')
+meuble(1.5, 3.2, 1.2, 0.6, 'Table basse')
+meuble(5.5, 1.5, 1.8, 0.7, 'Meuble TV')
+meuble(1, 5, 1.5, 0.6, 'Bibliothèque')
+meuble(3, 5, 1.5, 0.6, 'Bibliothèque')
+
+# Cuisine
+meuble(9, 1.5, 2.5, 0.6, 'Plan de travail')
+meuble(9, 2.5, 1, 0.6, 'Évier')
+meuble(10.5, 2.5, 1, 0.6, 'Plaques')
+meuble(9, 3.8, 2.5, 1, 'Îlot central')
+meuble(12, 1.5, 0.8, 0.6, 'Frigo')
+meuble(9, 5.5, 1.5, 0.6, 'Cellier')
+
+# Salle IT
+meuble(13.5, 1, 1.5, 0.6, 'Rack 42U', couleur=MEUBLE_IT)
+meuble(13.5, 2.2, 1.5, 0.6, 'Rack 24U', couleur=MEUBLE_IT)
+meuble(13.5, 3.5, 1, 0.6, 'Onduleur', couleur=MEUBLE_IT)
+meuble(14.5, 3.5, 0.8, 0.6, 'NAS', couleur=MEUBLE_IT)
+meuble(13.5, 5, 1.2, 0.5, 'Bureau IT', couleur=MEUBLE_IT)
+
+# Chambre 1
+meuble(16.5, 1.5, 1.6, 2, 'Lit 160')
+meuble(18.5, 1.5, 0.5, 0.5, '')
+meuble(19.5, 1.5, 0.5, 0.5, '')
+meuble(16.5, 5, 1.2, 0.5, 'Commode')
+meuble(18.5, 5, 1, 0.5, 'Bureau')
+
+# Salle de bain
+meuble(0.5, 10.5, 1.5, 0.8, 'Douche ital.')
+meuble(2.5, 10.5, 1, 0.6, 'Lavabo')
+meuble(0.5, 12, 1.5, 0.7, 'Baignoire')
+meuble(2.5, 12.5, 0.8, 0.6, 'WC')
+
+# WC
+meuble(4.5, 10.5, 0.8, 0.6, 'WC')
+meuble(4.5, 12, 0.6, 0.5, 'Lavabo')
+
+# Chambre 2
+meuble(7, 10.5, 1.4, 1.9, 'Lit 140')
+meuble(8.5, 10.5, 0.5, 0.5, '')
+meuble(7, 13, 1.2, 0.5, 'Commode')
+meuble(9, 13, 1, 0.5, 'Bureau')
+
+# Chambre 3
+meuble(12, 10.5, 1.6, 2, 'Lit 160')
+meuble(14, 10.5, 0.5, 0.5, '')
+meuble(15, 10.5, 0.5, 0.5, '')
+meuble(12, 13.5, 1.2, 0.5, 'Armoire')
+
+# Bureau
+meuble(17, 10.5, 1.8, 0.7, 'Bureau')
+meuble(17, 12, 1, 0.5, 'Étagère')
+meuble(18.5, 12, 0.8, 0.5, 'Imprimante')
+
+# ═══════════════════════════════════════════════════════════════
+# ÉTIQUETTES
+# ═══════════════════════════════════════════════════════════════
+label_piece(4, 4, 'SALON / SÉJOUR', '32 m²')
+label_piece(10.5, 4, 'CUISINE', '20 m²')
+label_piece(14.25,4, 'SALLE IT', '10 m²', sz_nom=10)
+label_piece(17.75,4, 'CHAMBRE 1', '18 m²')
+label_piece(2, 12.5, 'SALLE DE BAIN', '11 m²', sz_nom=9)
+label_piece(5, 12.5, 'WC', '5.5 m²', sz_nom=9)
+label_piece(8.5, 12.5, 'CHAMBRE 2', '13.5 m²')
+label_piece(13.5, 12.5, 'CHAMBRE 3', '13.5 m²')
+label_piece(18, 12.5, 'BUREAU', '11 m²')
+
+# Entrée
+ax.text(9.55, -0.5, 'ENTRÉE', ha='center', fontsize=10, fontweight='bold', color=TEXTE, zorder=8)
+ax.plot(9.55, -0.25, marker='v', color=PORTE_C, markersize=8, zorder=5)
+
+# ═══════════════════════════════════════════════════════════════
+# COTES
+# ═══════════════════════════════════════════════════════════════
+cote(0, -1.2, 20, -1.2, '20.00 m')
+cote(-1.2, 0, -1.2, 15, '15.00 m')
+cote(0, -0.7, 8, -0.7, '8.00 m')
+cote(8, -0.7, 13, -0.7, '5.00 m')
+cote(13, -0.7, 15.5, -0.7, '2.50 m')
+cote(15.5, -0.7, 20, -0.7, '4.50 m')
+
+# ═══════════════════════════════════════════════════════════════
+# LÉGENDE
+# ═══════════════════════════════════════════════════════════════
+lx, ly = 21.5, 14
+ax.text(lx, ly, 'LÉGENDE', fontsize=10, fontweight='bold', color=TEXTE, zorder=9)
+items = [
+ (MUR_EXT, 'Mur extérieur'),
+ (MUR, 'Mur intérieur'),
+ (FENETRE_C, 'Fenêtre'),
+ (PORTE_C, 'Porte'),
+ (MEUBLE_IT, 'Équipement IT'),
+]
+for i, (c, lbl) in enumerate(items):
+ y = ly - 0.7 - i*0.6
+ if lbl == 'Fenêtre':
+ ax.plot([lx, lx+1.5], [y, y], color=c, linewidth=4, zorder=9)
+ elif lbl == 'Porte':
+ ax.plot([lx, lx+0.8], [y, y], color=c, linewidth=2.5, zorder=9)
+ ax.add_patch(Circle((lx, y), 0.06, facecolor=c, edgecolor='none', zorder=9))
+ elif lbl == 'Mur extérieur':
+ ax.plot([lx, lx+1.5], [y, y], color=c, linewidth=LW_EXT, solid_capstyle='butt', zorder=9)
+ elif lbl == 'Mur intérieur':
+ ax.plot([lx, lx+1.5], [y, y], color=c, linewidth=LW, solid_capstyle='butt', zorder=9)
+ else:
+ ax.add_patch(Rectangle((lx, y-0.15), 1.5, 0.3, facecolor=c, edgecolor='none', zorder=9))
+ ax.text(lx+1.8, y, lbl, fontsize=7.5, color=TEXTE, va='center', zorder=9)
+
+# ═══════════════════════════════════════════════════════════════
+# TITRE
+# ═══════════════════════════════════════════════════════════════
+ax.text(10, 16.2, 'PLAN D\'ÉTAGE — MAISON FAMILIALE AVEC SALLE IT', ha='center',
+ fontsize=16, fontweight='bold', color=TEXTE, zorder=9)
+ax.text(10, 15.7, 'Rez-de-chaussée | Surface totale : ~150 m² | Échelle indicative',
+ ha='center', fontsize=9, color=COTE, zorder=9)
+
+ax.set_facecolor('#f0ede5')
+fig.patch.set_facecolor('#e8e4dc')
+ax.set_xlim(-2, 24)
+ax.set_ylim(-2, 17.5)
+
+output = '/root/workspace/plan_grande_maison.pdf'
+plt.savefig(output, format='pdf', dpi=200, bbox_inches='tight',
+ facecolor=fig.get_facecolor(), edgecolor='none')
+plt.close()
+print(f"✅ PDF : {output}")
diff --git a/one-shot/populate_gramps_sheet.py b/one-shot/populate_gramps_sheet.py
new file mode 100644
index 0000000..e2e0da6
--- /dev/null
+++ b/one-shot/populate_gramps_sheet.py
@@ -0,0 +1,533 @@
+#!/usr/bin/env python3
+"""Parse Gramps JSONL and populate a well-structured Google Sheet."""
+import json
+import urllib.request
+import urllib.parse
+from collections import defaultdict
+
+INPUT = "/root/.hermes/webui/attachments/8647800d8e2c/gramps-web-export-20260725204536.json"
+SPREADSHEET_ID = "1uRokfwe3-9PyYlKY8Vnv3fkbQxIh_E2X_QIk3QpqN6c"
+
+# ── Load data ──────────────────────────────────────────────────────────
+objects = []
+with open(INPUT) as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ if "|" in line and line[0].isdigit():
+ line = line.split("|", 1)[1]
+ objects.append(json.loads(line))
+
+by_handle = {o["handle"]: o for o in objects}
+persons = [o for o in objects if o.get("_class") == "Person"]
+families = [o for o in objects if o.get("_class") == "Family"]
+events = [o for o in objects if o.get("_class") == "Event"]
+places = [o for o in objects if o.get("_class") == "Place"]
+media_list = [o for o in objects if o.get("_class") == "Media"]
+
+EVENT_TYPES = {
+ 0: "Mariage", 1: "Mariage", 2: "Divorce",
+ 3: "Décès", 4: "Inhumation", 5: "Obsèques",
+ 6: "Naissance", 7: "Baptême", 8: "Adoption",
+ 9: "Baptême (LDS)", 10: "Endowment (LDS)",
+ 11: "Scellement (LDS)", 12: "Naissance", 13: "Décès",
+ 19: "Inhumation",
+}
+
+def event_type_str(t):
+ return EVENT_TYPES.get(t.get("value"), f"Type {t.get('value')}")
+
+def format_date(d):
+ if not d:
+ return ""
+ dv = d.get("dateval", [0, 0, 0])
+ y, m, day = dv[2], dv[1], dv[0]
+ if y == 0:
+ return ""
+ if m == 0:
+ return str(y)
+ if day == 0:
+ months = ["", "Jan", "Fév", "Mar", "Avr", "Mai", "Juin",
+ "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc"]
+ return f"{months[m]} {y}"
+ return f"{day:02d}/{m:02d}/{y}"
+
+def person_name(p):
+ if not p:
+ return ""
+ n = p.get("primary_name", {})
+ first = n.get("first_name", "").strip()
+ sur_list = n.get("surname_list", [])
+ sur = sur_list[0].get("surname", "").strip() if sur_list else ""
+ suffix = n.get("suffix", "").strip()
+ parts = [first, sur, suffix] if suffix else [first, sur]
+ return " ".join(p for p in parts if p).strip()
+
+def get_place_name(handle):
+ pl = by_handle.get(handle)
+ if pl:
+ return pl.get("name", {}).get("value", "")
+ return ""
+
+def get_notes_for_person(p):
+ notes = []
+ for nh in p.get("note_list", []):
+ n = by_handle.get(nh)
+ if n:
+ notes.append(n.get("text", {}).get("string", ""))
+ return notes
+
+def get_person_events(p):
+ birth, death = "", ""
+ for er in p.get("event_ref_list", []):
+ ev = by_handle.get(er.get("ref"))
+ if ev:
+ t = ev.get("type", {}).get("value")
+ d = format_date(ev.get("date"))
+ if t == 12:
+ birth = d
+ elif t == 13:
+ death = d
+ return birth, death
+
+def get_parents(person):
+ father, mother = None, None
+ for pf in person.get("parent_family_list", []):
+ fam = by_handle.get(pf)
+ if fam:
+ if fam.get("father_handle"):
+ father = by_handle.get(fam["father_handle"])
+ if fam.get("mother_handle"):
+ mother = by_handle.get(fam["mother_handle"])
+ return father, mother
+
+def get_children(family):
+ children = []
+ for cr in family.get("child_ref_list", []):
+ child = by_handle.get(cr.get("ref"))
+ if child:
+ children.append(child)
+ return children
+
+def get_spouses(person):
+ spouses = []
+ for f in families:
+ if f.get("father_handle") == person["handle"]:
+ s = by_handle.get(f.get("mother_handle"))
+ if s:
+ spouses.append(s)
+ elif f.get("mother_handle") == person["handle"]:
+ s = by_handle.get(f.get("father_handle"))
+ if s:
+ spouses.append(s)
+ return spouses
+
+def get_children_for_person(person):
+ all_children = []
+ for f in families:
+ if f.get("father_handle") == person["handle"] or f.get("mother_handle") == person["handle"]:
+ all_children.extend(get_children(f))
+ return all_children
+
+# ── Determine branch ───────────────────────────────────────────────────
+KELLER_ROOT = "a37f8646-d374-40da-863b-86febf23b165"
+STAERLE_ROOT = "1c80dd1f-77b4-4ab9-940c-e0776b6161ff"
+
+def compute_generation(person, root_handle, max_depth=20, visited=None):
+ if visited is None:
+ visited = set()
+ if person["handle"] in visited or max_depth <= 0:
+ return None
+ visited.add(person["handle"])
+ if person["handle"] == root_handle:
+ return 1
+ father, mother = get_parents(person)
+ for parent in [father, mother]:
+ if parent:
+ gen = compute_generation(parent, root_handle, max_depth - 1, visited)
+ if gen:
+ return gen + 1
+ return None
+
+def get_branch(person, visited=None):
+ if visited is None:
+ visited = set()
+ if person["handle"] in visited:
+ return "Autre", 99
+ visited.add(person["handle"])
+ keller_gen = compute_generation(person, KELLER_ROOT)
+ staerle_gen = compute_generation(person, STAERLE_ROOT)
+ if keller_gen:
+ return "Keller", keller_gen
+ if staerle_gen:
+ return "Staerle", staerle_gen
+ for s in get_spouses(person):
+ sk, sg = get_branch(s, visited)
+ if sk and sk != "Autre":
+ return f"Allié ({sk})", sg + 1
+ return "Autre", 99
+
+# ── Build data ──────────────────────────────────────────────────────────
+
+# 1. PERSONNES
+person_rows = [["ID", "Prénom", "Nom", "Nom complet", "Genre", "Naissance", "Décès",
+ "Branche", "Génération", "Père", "Mère",
+ "Conjoint(s)", "Enfants", "Profession/Notes"]]
+person_branches = []
+
+for p in persons:
+ pid = p.get("gramps_id", "")
+ n = p.get("primary_name", {})
+ first = n.get("first_name", "").strip()
+ sur_list = n.get("surname_list", [])
+ sur = sur_list[0].get("surname", "").strip() if sur_list else ""
+ gender_map = {0: "F", 1: "M", 2: "?"}
+ gender = gender_map.get(p.get("gender"), "?")
+ birth, death = get_person_events(p)
+ branch, gen = get_branch(p)
+ father, mother = get_parents(p)
+ father_name = person_name(father) if father else ""
+ mother_name = person_name(mother) if mother else ""
+ spouses = get_spouses(p)
+ spouse_names = ", ".join(person_name(s) for s in spouses)
+ children = get_children_for_person(p)
+ child_names = ", ".join(person_name(c) for c in children)
+ notes = "; ".join(get_notes_for_person(p))
+ person_rows.append([pid, first, sur, f"{first} {sur}", gender, birth, death,
+ branch, gen, father_name, mother_name,
+ spouse_names, child_names, notes])
+ person_branches.append(branch)
+
+# 2. FAMILLES
+family_rows = [["ID", "Père", "Mère", "Type", "Date mariage", "Lieu mariage", "Enfants"]]
+for f in families:
+ fid = f.get("gramps_id", "")
+ father = by_handle.get(f.get("father_handle"))
+ mother = by_handle.get(f.get("mother_handle"))
+ f_name = person_name(father) if father else "?"
+ m_name = person_name(mother) if mother else "?"
+ rel_type = f.get("type", {}).get("value", 0)
+ rel_str = "Marié" if rel_type == 0 else "Non marié" if rel_type == 3 else ""
+ marriage_date, marriage_place = "", ""
+ for er in f.get("event_ref_list", []):
+ ev = by_handle.get(er.get("ref"))
+ if ev:
+ marriage_date = format_date(ev.get("date"))
+ marriage_place = get_place_name(ev.get("place", ""))
+ children = get_children(f)
+ enfants = ", ".join(person_name(c) for c in children)
+ family_rows.append([fid, f_name, m_name, rel_str, marriage_date, marriage_place, enfants])
+
+# 3. ÉVÉNEMENTS
+event_rows = [["ID", "Type", "Date", "Personne", "Lieu", "Description"]]
+for ev in events:
+ eid = ev.get("gramps_id", "")
+ etype = event_type_str(ev.get("type", {}))
+ date = format_date(ev.get("date"))
+ place = get_place_name(ev.get("place", ""))
+ desc = ev.get("description", "")
+ person = ""
+ for p in persons:
+ for er in p.get("event_ref_list", []):
+ if er.get("ref") == ev["handle"]:
+ person = person_name(p)
+ break
+ if person:
+ break
+ event_rows.append([eid, etype, date, person, place, desc])
+
+# 4. LIEUX
+place_rows = [["ID", "Nom", "Type", "Latitude", "Longitude", "Fait partie de"]]
+for pl in places:
+ pid = pl.get("gramps_id", "")
+ name = pl.get("name", {}).get("value", "")
+ ptype = pl.get("place_type", {}).get("string", "")
+ lat = pl.get("lat", "")
+ lon = pl.get("long", "")
+ parent_place = ""
+ for pr in pl.get("placeref_list", []):
+ parent = by_handle.get(pr.get("ref"))
+ if parent:
+ parent_place = parent.get("name", {}).get("value", "")
+ place_rows.append([pid, name, ptype, lat, lon, parent_place])
+
+# 5. MÉDIAS
+media_rows = [["ID", "Description", "Type MIME", "Chemin", "Date"]]
+for m in media_list:
+ mid = m.get("gramps_id", "")
+ desc = m.get("desc", "")
+ mime = m.get("mime", "")
+ path = m.get("path", "")
+ date_str = format_date(m.get("date", {}))
+ media_rows.append([mid, desc, mime, path, date_str])
+
+# 6. STATISTIQUES
+stats_rows = [
+ ["Métrique", "Valeur"],
+ ["Total personnes", len(persons)],
+ ["Hommes", sum(1 for p in persons if p.get("gender") == 1)],
+ ["Femmes", sum(1 for p in persons if p.get("gender") == 0)],
+ ["Total familles", len(families)],
+ ["Familles mariées", sum(1 for f in families if f.get("type", {}).get("value") == 0)],
+ ["Total événements", len(events)],
+ ["Naissances", sum(1 for ev in events if ev.get("type", {}).get("value") in (6, 12))],
+ ["Décès", sum(1 for ev in events if ev.get("type", {}).get("value") in (3, 13))],
+ ["Mariages", sum(1 for ev in events if ev.get("type", {}).get("value") in (0, 1))],
+ ["Total lieux", len(places)],
+ ["Total médias", len(media_list)],
+ ["", ""],
+ ["Branche Keller", ""],
+ [" Personnes Keller", sum(1 for b in person_branches if b == "Keller")],
+ ["Branche Staerle", ""],
+ [" Personnes Staerle", sum(1 for b in person_branches if b and "Staerle" in b)],
+]
+
+# ── Write to Sheets ────────────────────────────────────────────────────
+TOKEN = open("/tmp/gsheet_token.txt").read().strip()
+
+def get_sheets():
+ url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}?fields=sheets.properties"
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {TOKEN}"})
+ resp = json.loads(urllib.request.urlopen(req).read())
+ return resp.get("sheets", [])
+
+def get_sheet_id(sheets, name):
+ for s in sheets:
+ if s["properties"]["title"] == name:
+ return s["properties"]["sheetId"]
+ return None
+
+def clear_sheet(sheet_id):
+ """Clear all content from a sheet."""
+ body = json.dumps({
+ "requests": [{
+ "updateCells": {
+ "range": {"sheetId": sheet_id},
+ "fields": "userEnteredValue"
+ }
+ }]
+ })
+ url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
+ req = urllib.request.Request(url, data=body.encode(),
+ method="POST",
+ headers={"Authorization": f"Bearer {TOKEN}",
+ "Content-Type": "application/json"})
+ urllib.request.urlopen(req)
+
+def add_sheet(title):
+ """Add a new sheet, return its ID."""
+ body = json.dumps({
+ "requests": [{
+ "addSheet": {
+ "properties": {
+ "title": title,
+ "gridProperties": {"rowCount": 2000, "columnCount": 26}
+ }
+ }
+ }]
+ })
+ url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
+ req = urllib.request.Request(url, data=body.encode(),
+ method="POST",
+ headers={"Authorization": f"Bearer {TOKEN}",
+ "Content-Type": "application/json"})
+ resp = json.loads(urllib.request.urlopen(req).read())
+ return resp["replies"][0]["addSheet"]["properties"]["sheetId"]
+
+def write_sheet(sheet_id, rows):
+ """Write rows to a sheet by ID."""
+ # Clear first
+ clear_sheet(sheet_id)
+
+ # Write data
+ body = json.dumps({"values": rows, "majorDimension": "ROWS"})
+ # Use the sheet title from the ID — we need to find it
+ write_url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}/values/A1?valueInputOption=USER_ENTERED"
+ write_req = urllib.request.Request(write_url, data=body.encode(),
+ method="PUT",
+ headers={"Authorization": f"Bearer {TOKEN}",
+ "Content-Type": "application/json"})
+ resp = json.loads(urllib.request.urlopen(write_req).read())
+ return resp.get('updatedCells', 0)
+
+def write_sheet_by_name(sheet_name, rows):
+ """Write rows to a sheet by name."""
+ encoded_name = urllib.parse.quote(sheet_name)
+ range_name = f"{encoded_name}!A1"
+ body = json.dumps({"values": rows, "majorDimension": "ROWS"})
+ write_url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}/values/{range_name}?valueInputOption=USER_ENTERED"
+ write_req = urllib.request.Request(write_url, data=body.encode(),
+ method="PUT",
+ headers={"Authorization": f"Bearer {TOKEN}",
+ "Content-Type": "application/json"})
+ resp = json.loads(urllib.request.urlopen(write_req).read())
+ return resp.get('updatedCells', 0)
+
+def format_sheet(sheet_id, num_cols, num_rows, name="Sheet", alt_color=None):
+ """Apply formatting: frozen header, auto-filter, alternating colors."""
+ requests = []
+
+ # Freeze header row
+ requests.append({
+ "updateSheetProperties": {
+ "properties": {
+ "sheetId": sheet_id,
+ "gridProperties": {"frozenRowCount": 1}
+ },
+ "fields": "gridProperties.frozenRowCount"
+ }
+ })
+
+ # Auto-filter
+ if num_rows > 1:
+ requests.append({
+ "setBasicFilter": {
+ "filter": {
+ "range": {
+ "sheetId": sheet_id,
+ "startRowIndex": 0,
+ "endRowIndex": num_rows,
+ "startColumnIndex": 0,
+ "endColumnIndex": num_cols
+ }
+ }
+ }
+ })
+
+ # Header: bold white on dark blue
+ requests.append({
+ "repeatCell": {
+ "range": {
+ "sheetId": sheet_id,
+ "startRowIndex": 0, "endRowIndex": 1,
+ "startColumnIndex": 0, "endColumnIndex": num_cols
+ },
+ "cell": {
+ "userEnteredFormat": {
+ "textFormat": {"bold": True, "foregroundColor": {"red": 1, "green": 1, "blue": 1}},
+ "backgroundColor": {"red": 0.15, "green": 0.23, "blue": 0.35},
+ "verticalAlignment": "MIDDLE"
+ }
+ },
+ "fields": "userEnteredFormat(textFormat,backgroundColor,verticalAlignment)"
+ }
+ })
+
+ # Alternating row colors
+ if alt_color and num_rows > 1:
+ for i in range(1, num_rows):
+ if i % 2 == 0:
+ requests.append({
+ "repeatCell": {
+ "range": {
+ "sheetId": sheet_id,
+ "startRowIndex": i, "endRowIndex": i + 1,
+ "startColumnIndex": 0, "endColumnIndex": num_cols
+ },
+ "cell": {"userEnteredFormat": {"backgroundColor": alt_color}},
+ "fields": "userEnteredFormat.backgroundColor"
+ }
+ })
+
+ # Auto-resize columns
+ requests.append({
+ "autoResizeDimensions": {
+ "dimensions": {
+ "sheetId": sheet_id,
+ "dimension": "COLUMNS",
+ "startIndex": 0, "endIndex": num_cols
+ }
+ }
+ })
+
+ body = json.dumps({"requests": requests})
+ url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
+ req = urllib.request.Request(url, data=body.encode(),
+ method="POST",
+ headers={"Authorization": f"Bearer {TOKEN}",
+ "Content-Type": "application/json"})
+ urllib.request.urlopen(req)
+ print(f" ✓ {name}: formatted")
+
+# ── Main ───────────────────────────────────────────────────────────────
+print("Setting up sheets...")
+sheets = get_sheets()
+existing_names = {s["properties"]["title"] for s in sheets}
+
+# Add missing sheets
+for name in ["Médias", "Statistiques"]:
+ if name not in existing_names:
+ add_sheet(name)
+ print(f" ✓ Added sheet '{name}'")
+
+# Write data to all sheets
+print("\nWriting data...")
+sheets = get_sheets()
+
+for name, rows in [("Personnes", person_rows), ("Familles", family_rows),
+ ("Événements", event_rows), ("Lieux", place_rows),
+ ("Médias", media_rows), ("Statistiques", stats_rows)]:
+ sid = get_sheet_id(sheets, name)
+ if sid:
+ cells = write_sheet_by_name(name, rows)
+ print(f" ✓ {name}: {cells} cells written")
+ else:
+ print(f" ✗ {name}: sheet not found")
+
+# Format all sheets
+print("\nFormatting...")
+sheets = get_sheets()
+
+formats = [
+ ("Personnes", person_rows, {"red": 0.95, "green": 0.97, "blue": 1.0}),
+ ("Familles", family_rows, {"red": 1.0, "green": 0.97, "blue": 0.95}),
+ ("Événements", event_rows, {"red": 0.95, "green": 0.95, "blue": 0.95}),
+ ("Lieux", place_rows, {"red": 0.97, "green": 0.97, "blue": 0.95}),
+ ("Médias", media_rows, {"red": 0.95, "green": 0.95, "blue": 0.97}),
+ ("Statistiques", stats_rows, {"red": 0.95, "green": 0.95, "blue": 0.95}),
+]
+for name, rows, color in formats:
+ sid = get_sheet_id(sheets, name)
+ if sid:
+ format_sheet(sid, len(rows[0]), len(rows), name=name, alt_color=color)
+
+# ── Color-code by branch on Personnes ──────────────────────────────────
+print("\nColoring by branch...")
+sid_p = get_sheet_id(sheets, "Personnes")
+if sid_p:
+ branch_colors = {
+ "Keller": {"red": 0.85, "green": 0.92, "blue": 1.0},
+ "Staerle": {"red": 0.85, "green": 1.0, "blue": 0.85},
+ }
+ for i, branch in enumerate(person_branches):
+ row_idx = i + 1
+ color = None
+ for key, c in branch_colors.items():
+ if branch and key in branch:
+ color = c
+ break
+ if color:
+ req_body = json.dumps({
+ "requests": [{
+ "repeatCell": {
+ "range": {
+ "sheetId": sid_p,
+ "startRowIndex": row_idx, "endRowIndex": row_idx + 1,
+ "startColumnIndex": 0, "endColumnIndex": len(person_rows[0])
+ },
+ "cell": {"userEnteredFormat": {"backgroundColor": color}},
+ "fields": "userEnteredFormat.backgroundColor"
+ }
+ }]
+ })
+ url = f"https://sheets.googleapis.com/v4/spreadsheets/{SPREADSHEET_ID}:batchUpdate"
+ req = urllib.request.Request(url, data=req_body.encode(),
+ method="POST",
+ headers={"Authorization": f"Bearer {TOKEN}",
+ "Content-Type": "application/json"})
+ urllib.request.urlopen(req)
+ print(f" ✓ Personnes: {len(person_branches)} rows colored by branch")
+
+print(f"\n✅ Done! Open: https://docs.google.com/spreadsheets/d/{SPREADSHEET_ID}/edit")
+print(f" Personnes: {len(person_rows)-1} | Familles: {len(family_rows)-1} | Événements: {len(event_rows)-1}")
+print(f" Lieux: {len(place_rows)-1} | Médias: {len(media_rows)-1} | Statistiques: {len(stats_rows)-1}")