Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/integrations/HERMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,14 +26,31 @@ 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
documents synthétiques de démonstration. Pour l’ouvrir à tes cours, définis explicitement un dossier :

```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"
```

Expand Down
67 changes: 67 additions & 0 deletions mcp/elos-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/data/page.tsx
Original file line number Diff line number Diff line change
@@ -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" };

Expand All @@ -9,6 +10,7 @@ export default function DataPage() {
<div className="space-y-6">
<CanonicalDataWorkspace />
<DailyEnglishBackupCard />
<AgentBridgePanel />
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -72,6 +73,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<CanonicalLearningHistoryBridge />
<RemoteSyncBridge />
<ServiceWorkerRegistration />
<AgentAutoSync />
<a href="#main-content" className="fixed left-4 top-4 z-[100] -translate-y-24 rounded-lg bg-emerald-950 px-4 py-2 text-sm font-semibold text-white shadow-lg transition-transform focus:translate-y-0 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2">Aller au contenu</a>

<aside className="fixed inset-y-0 left-0 z-40 hidden w-60 border-r border-slate-200/80 bg-white px-4 py-5 md:flex md:flex-col lg:w-64 lg:px-5">
Expand Down
23 changes: 23 additions & 0 deletions src/components/learner-snapshot/AgentAutoSync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useEffect } from "react";
import { savedFolder, syncSnapshot } from "@/modules/learner-snapshot/browser-bridge";

const INTERVAL_MS = 60_000;

/** Keeps the shared snapshot fresh while the app is open; never prompts for permission. */
export function AgentAutoSync() {
useEffect(() => {
let stopped = false;
const tick = async () => {
const folder = await savedFolder();
if (!stopped && folder) await syncSnapshot(folder).catch(() => {});
};
const onHide = () => { if (document.visibilityState === "hidden") void tick(); };
void tick();
const timer = setInterval(() => void tick(), INTERVAL_MS);
document.addEventListener("visibilitychange", onHide);
return () => { stopped = true; clearInterval(timer); document.removeEventListener("visibilitychange", onHide); };
}, []);
return null;
}
57 changes: 57 additions & 0 deletions src/components/learner-snapshot/AgentBridgePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { useEffect, useState, useSyncExternalStore } from "react";
import { Bot, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SNAPSHOT_FILE, chooseFolder, downloadSnapshot, folderSyncSupported, forgetFolder, savedFolder, syncSnapshot } from "@/modules/learner-snapshot/browser-bridge";

type Folder = Awaited<ReturnType<typeof savedFolder>>;
const noSubscription = () => () => {};

/** Lets the learner share a minimal, read-only progress snapshot with a local agent (e.g. Hermes). */
export function AgentBridgePanel() {
const [folder, setFolder] = useState<Folder>(null);
const [status, setStatus] = useState<string | null>(null);
// Server render cannot know the browser API: decide on the client only (no hydration mismatch).
const supported = useSyncExternalStore(noSubscription, folderSyncSupported, () => false);

useEffect(() => { savedFolder().then(setFolder).catch(() => {}); }, []);

async function run(action: () => Promise<string>) {
try { setStatus(await action()); } catch (error) { setStatus(error instanceof Error && error.name === "AbortError" ? "Choix annulé." : "Opération impossible."); }
}

const describe = (result: string) => result === "written" ? `${SNAPSHOT_FILE} mis à jour.` : result === "unchanged" ? "Déjà à jour." : "Autorisation nécessaire : clique sur « Synchroniser maintenant ».";

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Bot className="size-5 text-emerald-700" aria-hidden="true" />Agent local (Hermes)</CardTitle>
<CardDescription>
Partage avec ton agent un résumé en lecture seule : mots et cartes à réviser, difficultés, examens à venir, série de jours.
Jamais tes phrases, transcriptions, notes, réponses ni passages de cours. L’agent peut l’envoyer à son fournisseur de modèle.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{supported ? (
folder ? (
<>
<p>Dossier : <b>{folder.name}</b> · fichier <code>{SNAPSHOT_FILE}</code>, mis à jour automatiquement tant que l’application est ouverte.</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => run(async () => describe(await syncSnapshot(folder, { force: true, request: true })))}>Synchroniser maintenant</Button>
<Button type="button" size="sm" variant="outline" onClick={() => run(async () => { await forgetFolder(); setFolder(null); return "Dossier oublié : plus aucune écriture."; })}>Arrêter le partage</Button>
</div>
</>
) : (
<Button type="button" size="sm" onClick={() => run(async () => { const handle = await chooseFolder(); setFolder(handle); return describe(await syncSnapshot(handle, { force: true, request: true })); })}>
<FolderOpen className="mr-1.5 size-4" aria-hidden="true" />Choisir le dossier partagé avec l’agent
</Button>
)
) : <p className="text-slate-600">Ce navigateur ne permet pas d’écrire dans un dossier : utilise le téléchargement ci-dessous (ou Chrome).</p>}
<Button type="button" size="sm" variant="ghost" onClick={() => run(async () => { await downloadSnapshot(); return `${SNAPSHOT_FILE} téléchargé.`; })}>Télécharger {SNAPSHOT_FILE}</Button>
{status && <p role="status" className="text-xs text-emerald-800">{status}</p>}
</CardContent>
</Card>
);
}
107 changes: 107 additions & 0 deletions src/modules/learner-snapshot/browser-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import { readLocalState } from "../daily-english/local-mission";
import { CURRENT_SEMESTER_STATE } from "../current-semester/registry";
import { deriveFlashcards, readStudioState } from "../learning-studio/core";
import { listImportedDocuments } from "../learning-studio/browser-library";
import { SYNTHETIC_DOCUMENTS } from "../learning-studio/fixtures";
import { buildLearnerSnapshot, type LearnerSnapshot } from "./core";

// Writes the learner snapshot to a folder chosen by the learner (File System Access
// API, Chrome / Edge), so a local agent can read it. Nothing is uploaded anywhere.

export const SNAPSHOT_FILE = "elos-state.json";
const DB = "engineer-learning-os-agent-bridge";
const STORE = "handles";
const KEY = "snapshot-folder";

type Permission = "granted" | "denied" | "prompt";
type DirectoryHandle = {
name: string;
queryPermission(options: { mode: "readwrite" }): Promise<Permission>;
requestPermission(options: { mode: "readwrite" }): Promise<Permission>;
getFileHandle(name: string, options: { create: boolean }): Promise<{ createWritable(): Promise<{ write(data: string): Promise<void>; close(): Promise<void> }> }>;
};
type PickerWindow = Window & { showDirectoryPicker?: (options: { mode: "readwrite"; id?: string }) => Promise<DirectoryHandle> };

export function folderSyncSupported(): boolean {
return typeof window !== "undefined" && typeof (window as PickerWindow).showDirectoryPicker === "function";
}

function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

async function idb<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const db = await openDb();
try {
return await new Promise<T>((resolve, reject) => {
const request = run(db.transaction(STORE, mode).objectStore(STORE));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} finally { db.close(); }
}

export async function savedFolder(): Promise<DirectoryHandle | null> {
try { return (await idb("readonly", (store) => store.get(KEY) as IDBRequest<DirectoryHandle | undefined>)) ?? null; } catch { return null; }
}

export async function forgetFolder(): Promise<void> {
await idb("readwrite", (store) => store.delete(KEY));
}

export async function chooseFolder(): Promise<DirectoryHandle> {
const picker = (window as PickerWindow).showDirectoryPicker;
if (!picker) throw new Error("FOLDER_SYNC_UNSUPPORTED");
const handle = await picker({ mode: "readwrite", id: "elos-agent-bridge" });
await idb("readwrite", (store) => store.put(handle, KEY));
return handle;
}

export async function collectSnapshot(now = new Date()): Promise<LearnerSnapshot> {
const imported = await listImportedDocuments().catch(() => []);
const library = [...SYNTHETIC_DOCUMENTS, ...imported];
let storage: Storage | null = null;
try { storage = window.localStorage; } catch { storage = null; }
return buildLearnerSnapshot({
dailyEnglish: readLocalState(storage),
dailyStorage: storage,
studio: readStudioState(storage),
cards: deriveFlashcards(library),
documentTitles: Object.fromEntries(library.map((document) => [document.id, document.title])),
exams: CURRENT_SEMESTER_STATE.courses.map((course) => ({ course: course.title, date: course.examDate, priority: course.priority })),
now,
});
}

const comparable = (snapshot: LearnerSnapshot) => JSON.stringify({ ...snapshot, generatedAt: "" });
let lastWritten = "";

/** Writes the snapshot if the folder is still authorised and the content changed. Returns what happened. */
export async function syncSnapshot(handle: DirectoryHandle, options: { force?: boolean; request?: boolean } = {}): Promise<"written" | "unchanged" | "permission-needed"> {
let permission = await handle.queryPermission({ mode: "readwrite" });
if (permission !== "granted" && options.request) permission = await handle.requestPermission({ mode: "readwrite" });
if (permission !== "granted") return "permission-needed";
const snapshot = await collectSnapshot();
const key = comparable(snapshot);
if (!options.force && key === lastWritten) return "unchanged";
const writable = await (await handle.getFileHandle(SNAPSHOT_FILE, { create: true })).createWritable();
await writable.write(JSON.stringify(snapshot, null, 2));
await writable.close();
lastWritten = key;
return "written";
}

export async function downloadSnapshot(): Promise<void> {
const blob = new Blob([JSON.stringify(await collectSnapshot(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url; anchor.download = SNAPSHOT_FILE; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Loading
Loading