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