From a6a8fb8a643ebf0b00e58e35d8b3340617a7cd0e Mon Sep 17 00:00:00 2001 From: Euloge Date: Fri, 23 Oct 2026 07:17:16 +0200 Subject: [PATCH] feat(elos): share a read-only learner snapshot with local agents (Hermes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App side (Données → Agent local): - the learner picks a folder once (File System Access API, Chrome/Edge); ELOS writes elos-state.json there and keeps it fresh while open (every minute and when the tab is hidden, only if changed, never prompting); download fallback for other browsers; sharing can be stopped - minimal content: words and cards due, difficulties, upcoming exams, streak, scheduler replay summary. Never personal sentences, transcriptions, notes, answers or source passages (tested) MCP side: elos_today, elos_progress and elos_exam_plan read the file from ELOS_STATE_FILE, validate it strictly, flag snapshots older than 24 h and never write back or grant mastery. Co-Authored-By: Claude Opus 5.5 --- docs/integrations/HERMES.md | 22 +++- mcp/elos-server.ts | 67 +++++++++++ src/app/data/page.tsx | 2 + src/components/app-shell/AppShell.tsx | 2 + .../learner-snapshot/AgentAutoSync.tsx | 23 ++++ .../learner-snapshot/AgentBridgePanel.tsx | 57 +++++++++ .../learner-snapshot/browser-bridge.ts | 107 +++++++++++++++++ src/modules/learner-snapshot/core.ts | 111 ++++++++++++++++++ tests/integration/mcp/elos-server.test.ts | 43 ++++++- tests/unit/learner-snapshot/snapshot.test.ts | 64 ++++++++++ 10 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 src/components/learner-snapshot/AgentAutoSync.tsx create mode 100644 src/components/learner-snapshot/AgentBridgePanel.tsx create mode 100644 src/modules/learner-snapshot/browser-bridge.ts create mode 100644 src/modules/learner-snapshot/core.ts create mode 100644 tests/unit/learner-snapshot/snapshot.test.ts diff --git a/docs/integrations/HERMES.md b/docs/integrations/HERMES.md index c78ee68..065cc1d 100644 --- a/docs/integrations/HERMES.md +++ b/docs/integrations/HERMES.md @@ -11,6 +11,9 @@ Aucun accès réseau ; les modules utilisés sont ceux de l’application (Studi | `elos_roleplay` | Jeux de rôle oraux écrits à la main (stand-up, incident, entretien), évaluation locale | | `elos_vocabulary` | Vocabulaire Daily English (150 mots, 5 packs) | | `elos_compare_schedulers` | Rejeu de journaux de révision : FSRS-4.5 vs Leitner ELOS | +| `elos_today` | Quoi faire aujourd’hui : mission Daily English, mots et cartes dus, difficultés, examen proche | +| `elos_progress` | Série de jours, mots vus / maîtrisés, charge des 7 prochains jours, comparaison FSRS sur ton historique | +| `elos_exam_plan` | Compte à rebours des examens et séances de révision espacées (J-14, J-7, J-3, J-1) | ## Installation @@ -23,6 +26,22 @@ hermes mcp test elos # connexion + 6 outils Dans une session Hermes déjà ouverte : `/reload-mcp`. +## Ta progression (lecture seule) + +Les trois derniers outils lisent un résumé écrit par l’application, jamais ta base : + +1. Dans ELOS (Chrome ou Edge) : **Données → Agent local (Hermes) → Choisir le dossier partagé avec l’agent** + (par exemple `~/ELOS-Hermes`). ELOS y écrit `elos-state.json` et le tient à jour tant que l’app est ouverte. + Autres navigateurs : bouton « Télécharger elos-state.json ». +2. Indique ce fichier au serveur avec `--env ELOS_STATE_FILE=~/ELOS-Hermes/elos-state.json` (voir ci-dessous). + +Le résumé contient : mots et cartes à réviser, difficultés, examens à venir, série de jours. Il ne contient jamais +tes phrases, transcriptions, notes, réponses ni passages de cours. Il ne permet pas d’écrire dans ELOS et n’accorde +aucune maîtrise. Un résumé de plus de 24 h est signalé comme ancien. + +Limite : en mode SQLite local (serveur avec base), la mission Daily English est en base et n’apparaît pas dans le +résumé ; les révisions du Studio, elles, y sont. + ## Confidentialité Les résultats des outils sont envoyés par Hermes à **son** fournisseur de modèle. Par défaut le serveur n’utilise que les @@ -30,7 +49,8 @@ documents synthétiques de démonstration. Pour l’ouvrir à tes cours, défini ```bash hermes mcp remove elos -hermes mcp add elos --command "$(command -v node)" --env ELOS_SOURCES_DIR=/chemin/vers/mes-cours --args \ +hermes mcp add elos --command "$(command -v node)" \ + --env ELOS_SOURCES_DIR=/chemin/vers/mes-cours ELOS_STATE_FILE=$HOME/ELOS-Hermes/elos-state.json --args \ --experimental-strip-types --no-warnings --import "$REPO/tests/register-loader.mjs" "$REPO/mcp/elos-server.ts" ``` diff --git a/mcp/elos-server.ts b/mcp/elos-server.ts index acc2d33..6a1b701 100644 --- a/mcp/elos-server.ts +++ b/mcp/elos-server.ts @@ -22,6 +22,7 @@ import { analyzeProduction } from "../src/modules/daily-english/local-mission.ts import { ROLEPLAY_SCENARIOS, evaluateTurn } from "../src/modules/daily-english/roleplay.ts"; import { VOCABULARY, VOCABULARY_PACKS } from "../src/modules/daily-english/vocabulary.ts"; import { replaySchedulers, type ReviewEvent } from "../src/modules/scientific-pedagogy/replay.ts"; +import { parseLearnerSnapshot, type LearnerSnapshot } from "../src/modules/learner-snapshot/core.ts"; const MAX_FILES = 200; const MAX_FILE_BYTES = 25 * 1024 * 1024; @@ -98,6 +99,20 @@ const cite = (documents: readonly StudioDocument[], citation: { documentId: stri return { source: document?.title ?? citation.documentId, [document?.unit === "section" ? "section" : "page"]: citation.pageNumber, quote: citation.quote }; }; +const STALE_MS = 24 * 3_600_000; + +/** Reads the learner snapshot written by the app (ELOS_STATE_FILE); read-only, strictly validated. */ +export function readSnapshot(env: NodeJS.ProcessEnv): { snapshot: LearnerSnapshot; stale: boolean } | { error: string } { + const file = env.ELOS_STATE_FILE?.trim(); + if (!file) return { error: "ELOS_STATE_FILE is not set: in the ELOS app, open Données → Agent local (Hermes) and choose the shared folder, then point ELOS_STATE_FILE to elos-state.json." }; + try { + const snapshot = parseLearnerSnapshot(readFileSync(path.resolve(file), "utf8")); + return { snapshot, stale: Date.now() - Date.parse(snapshot.generatedAt) > STALE_MS }; + } catch { + return { error: "The learner snapshot is missing or invalid. Open the ELOS app once so it can write elos-state.json." }; + } +} + export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { const root = sourcesRoot(env); const server = new McpServer({ name: "elos", version: "0.3.0" }); @@ -186,6 +201,58 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { })); }); + const freshness = (stale: boolean, snapshot: LearnerSnapshot) => (stale ? `Snapshot from ${snapshot.generatedAt} (older than 24 h): ask the learner to open ELOS to refresh it.` : null); + + server.registerTool("elos_today", { + title: "What to study today", + description: "Read-only view of the learner's day from the ELOS snapshot: today's Daily English words and status, words and course cards due, top difficulties and the closest exam. Suggests an order; never grants mastery.", + inputSchema: {}, + }, async () => { + const read = readSnapshot(env); + if ("error" in read) return json(read); + const { snapshot, stale } = read; + const nextExam = snapshot.exams[0] ?? null; + const plan = [ + snapshot.dailyEnglish.todayMission && !snapshot.dailyEnglish.todayMission.completed ? `Daily English mission (${snapshot.dailyEnglish.todayMission.words.length} words)` : null, + snapshot.dailyEnglish.dueWords.length ? `Review ${snapshot.dailyEnglish.dueWords.length} English word(s) due` : null, + snapshot.studio.dueToday.length ? `Review ${snapshot.studio.dueToday.length} course card(s) in the Studio` : null, + snapshot.studio.difficulties.length ? `Retrieval practice on difficulties: ${snapshot.studio.difficulties.slice(0, 3).map((item) => item.term).join(", ")}` : null, + nextExam && nextExam.daysLeft <= 14 ? `Mock exam for ${nextExam.course} (in ${nextExam.daysLeft} days)` : null, + ].filter(Boolean); + return json({ today: snapshot.today, warning: freshness(stale, snapshot), suggestedOrder: plan, dailyEnglish: snapshot.dailyEnglish.todayMission, + dueWords: snapshot.dailyEnglish.dueWords, dueCards: snapshot.studio.dueToday, difficulties: snapshot.studio.difficulties, nextExam }); + }); + + server.registerTool("elos_progress", { + title: "Learning progress overview", + description: "Read-only progress from the ELOS snapshot: Daily English streak and words seen/mastered, Studio workload for the next 7 days, difficulties, scheduler comparison (FSRS vs Leitner) on the learner's own history when enough data exists.", + inputSchema: {}, + }, async () => { + const read = readSnapshot(env); + if ("error" in read) return json(read); + const { snapshot, stale } = read; + return json({ generatedAt: snapshot.generatedAt, warning: freshness(stale, snapshot), dailyEnglish: { streak: snapshot.dailyEnglish.streak, wordsSeen: snapshot.dailyEnglish.wordsSeen, wordsMastered: snapshot.dailyEnglish.wordsMastered, totalWords: snapshot.dailyEnglish.totalWords }, + studio: { scheduler: snapshot.studio.scheduler, next7Days: snapshot.studio.next7Days, difficulties: snapshot.studio.difficulties, schedulerReplay: snapshot.studio.schedulerReplay } }); + }); + + server.registerTool("elos_exam_plan", { + title: "Exam countdown and revision plan", + description: "Upcoming exams from the ELOS snapshot with days left and a spaced revision schedule (review sessions at J-14, J-7, J-3, J-1 when still ahead). Read-only.", + inputSchema: {}, + }, async () => { + const read = readSnapshot(env); + if ("error" in read) return json(read); + const { snapshot, stale } = read; + const offsets = [14, 7, 3, 1]; + return json({ warning: freshness(stale, snapshot), exams: snapshot.exams.map((exam) => ({ + ...exam, + sessions: offsets.filter((offset) => exam.daysLeft >= offset).map((offset) => { + const day = new Date(Date.parse(`${exam.date}T00:00:00Z`) - offset * 86_400_000).toISOString().slice(0, 10); + return { day, label: `J-${offset}`, activity: offset === 1 ? "light review + flashcards" : offset === 3 ? "timed mock exam" : "Studio retrieval practice on the course" }; + }), + })) }); + }); + return server; } diff --git a/src/app/data/page.tsx b/src/app/data/page.tsx index 632ae3f..42ad03d 100644 --- a/src/app/data/page.tsx +++ b/src/app/data/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { CanonicalDataWorkspace } from "@/components/learning-history/CanonicalDataWorkspace"; import { DailyEnglishBackupCard } from "@/components/daily-mission/DailyEnglishBackupCard"; +import { AgentBridgePanel } from "@/components/learner-snapshot/AgentBridgePanel"; export const metadata: Metadata = { title: "Données locales" }; @@ -9,6 +10,7 @@ export default function DataPage() {
+
); } diff --git a/src/components/app-shell/AppShell.tsx b/src/components/app-shell/AppShell.tsx index 812f0a7..ec7af58 100644 --- a/src/components/app-shell/AppShell.tsx +++ b/src/components/app-shell/AppShell.tsx @@ -14,6 +14,7 @@ import { RemoteSyncBridge } from "@/components/learning-history/RemoteSyncBridge import { RemoteModeBadge } from "@/components/learning-history/RemoteModeBadge"; import { DailyEnglishRecordBridge } from "@/components/daily-mission/DailyEnglishRecordBridge"; import { ServiceWorkerRegistration } from "@/components/app-shell/ServiceWorkerRegistration"; +import { AgentAutoSync } from "@/components/learner-snapshot/AgentAutoSync"; type NavItem = { href: string; label: string; icon: LucideIcon }; const navItems: NavItem[] = [ @@ -72,6 +73,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { + Aller au contenu