diff --git a/README.md b/README.md
index 0f81e76..093f1a5 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
🎬 Presentation •
🚀 Live Demo •
Features •
+ AI Agent •
Architecture •
Quick Start •
Modules •
@@ -49,7 +50,7 @@
Traditional e-learning platforms rely on **passive video watching**, **superficial badges**, and **arbitrary completion percentages** that fail to build real engineering intuition.
-**Engineer Learning OS** is a local-first platform designed to turn engineering course materials and technical challenges into **verifiable mastery**. It combines **zero-hallucination document extraction**, **grounded academic quizzes**, **spaced error retrieval**, and **integrated technical English** into a distraction-free modular monolith.
+**Engineer Learning OS** is a local-first platform designed to turn engineering course materials and technical challenges into **verifiable mastery**. It combines **zero-hallucination document extraction**, **grounded academic quizzes**, **spaced error retrieval**, and **integrated technical English** into a distraction-free modular monolith — and it can follow you to your phone through a **local AI tutor** (Hermes Agent over MCP) on Telegram and WhatsApp.
> ⚡ **Core Philosophy:** No fake progress. Every competency level (`NOT_SEEN` ➔ `PRACTICED` ➔ `VERIFIED`) is derived purely from **auditable, reproducible evidence records**.
@@ -99,6 +100,40 @@ Traditional e-learning platforms rely on **passive video watching**, **superfici
- **Context-Aware Practice:** Confidence calibration, bounded assistance, semantic self-explanation, transfer evidence, and curated concept relations inform the next action.
- **Canonical Shadow Projection:** The policy reads privacy-safe IndexedDB event metadata without rewriting EventIds or independently awarding mastery.
+### 📚 7. Learning Studio (`learning-studio`) — `/studio`
+- **Your own courses, on your device:** import PDF, Markdown or text files; text is extracted with PDF.js in the browser, and scanned PDFs are read by **on-device OCR** (tesseract.js, French model vendored — no upload).
+- **Cited answers only:** questions are answered with sentences quoted from your sources (page or section), or the Studio says the sources do not contain the answer. A Socratic mode gives graduated hints instead.
+- **Study material from the sources:** flashcards, quizzes, revision sheets, mind maps, prerequisite paths, an audio overview read by a local voice, annotations, and timed **mock exams** — all marked `DERIVED_MATERIAL`, never mastery.
+- **Scheduling:** Leitner ladder by default, with an optional **FSRS-4.5** scheduler (evaluated by replay on real public review logs, where it beat Leitner on log-loss).
+- **Programme import with quality audit:** a pedagogical base export (courses, exercises, flashcards) becomes a course map; an automatic audit blocks guessable multiple-choice banks (correct answer always the longest, generic distractors) and copy-pasted flashcards.
+- **Offline:** a service worker keeps the app usable without a network.
+
+### 🗣️ 8. Daily English Mission (`daily-english`) — `/daily-english`
+- **150 words in 5 packs** (core, networking, cybersecurity, meetings, career) with Leitner spaced review and a daily mission.
+- **Hand-written professional roleplays** (stand-up, incident report, apprenticeship interview) evaluated locally, plus on-device Whisper transcription and a full local backup / restore.
+
+---
+
+## 🤖 AI Agent Integration (Hermes + MCP)
+
+Engineer Learning OS ships a local **[Model Context Protocol](https://modelcontextprotocol.io/) server** (`mcp/elos-server.ts`) so a personal agent such as **Hermes Agent** can tutor you — including from **Telegram and WhatsApp** on your phone.
+
+| Tool | What the agent can do |
+|---|---|
+| `elos_ask_sources` | Answer only with quoted sentences from your course files (page citations), or a Socratic question with hints |
+| `elos_study_pack` | Flashcards, quiz, revision sheet, audio overview script, prerequisite paths |
+| `elos_course_map` | Programme map: objectives, prerequisites, worked example, analogy, frequent mistakes, quality audit |
+| `elos_course_exercise` | Multiple-choice questions built from **your own course files** for a chapter, with citations; answer revealed only after your attempt; missed questions come back first (`retry`) |
+| `elos_today` · `elos_progress` · `elos_exam_plan` | What to study today, progress, exam countdown with spaced sessions (read-only snapshot) |
+| `elos_correct_english` · `elos_roleplay` · `elos_vocabulary` | Local English correction, roleplays, vocabulary |
+| `elos_compare_schedulers` | Replay review logs: FSRS-4.5 vs Leitner |
+
+- **Daily rhythm:** a morning coach at 8:00 and an evening review at 19:00 (missed questions to redo) are delivered to Telegram/WhatsApp by Hermes cron jobs — deterministic messages, no model involved.
+- **Two-way, read-only by design:** the app shares a minimal snapshot (`elos-state.json`); the agent writes back practice counts per chapter (`elos-practice.json`), shown in the app as assisted practice that **never changes a mastery level**.
+- **Guard rails:** course files are read only from `ELOS_SOURCES_DIR`; `knowledge-private/`, secrets and path traversal are refused; answer keys are kept outside the folder the agent browses (`npm run mcp:split-base`); scanned PDFs are OCR'd on the Mac with Apple Vision and cached outside the shared folder.
+
+Setup, privacy notes and cron recipes: **[`docs/integrations/HERMES.md`](docs/integrations/HERMES.md)**.
+
---
## 🖼️ Product Tour
@@ -135,6 +170,13 @@ flowchart TD
Review["/review (Spaced Retrieval)"]
Evidence["/evidence (Audit Trail)"]
Progress["/progress (Competency Matrix)"]
+ Studio["/studio (Learning Studio)"]
+ English["/daily-english (Missions & Roleplays)"]
+ end
+
+ subgraph Agent ["🤖 Local Agent Bridge"]
+ MCP["mcp/elos-server.ts\n(MCP stdio, 11 tools)"]
+ Hermes["Hermes Agent\n(Telegram · WhatsApp · cron)"]
end
subgraph Core ["🧠 Pure Business Modules (src/modules/)"]
@@ -148,6 +190,8 @@ flowchart TD
DM["deep-mastery\n(Mental Model Invariants)"]
MR["mission-runtime\n(Active Time & Session Engine)"]
SP["scientific-pedagogy\n(Evidence-Informed Policy)"]
+ LS["learning-studio\n(Cited Answers, Exams, Programme Audit)"]
+ SN["learner-snapshot\n(Agent Bridge & Coach)"]
end
subgraph Infra ["🔌 Infrastructure & Local Storage"]
@@ -161,6 +205,8 @@ flowchart TD
UI --> Core
Core --> Infra
+ Hermes --> MCP
+ MCP --> Core
```
---
@@ -192,7 +238,14 @@ npm test # node:test suites, no extra flag needed
```
`npm test` runs the unit and integration suites with Node's built-in test runner. A small resolver (`tests/register-loader.mjs`) is registered automatically, so the command works as written from a fresh clone.
-### 4. Build for Production
+### 4. Optional: connect a local agent
+```bash
+npm run mcp:elos # MCP server over stdio (see docs/integrations/HERMES.md)
+npm run mcp:ocr # pre-OCR scanned PDFs on macOS (Apple Vision), cached locally
+npm run mcp:split-base -- # move a pedagogical base's answer key out of the shared folder
+```
+
+### 5. Build for Production
```bash
npm run build
```
@@ -217,6 +270,10 @@ src/modules/
├── deep-mastery/ # Deliberate practice on fundamental invariants (CSV, encodings)
├── scientific-pedagogy/ # Evidence-traceable scheduling, assistance, calibration, transfer policy
├── technical-english/ # Spoken/written technical English & audio evidence persistence
+├── learning-studio/ # Source library, cited answers, OCR import, mock exams, programme audit, chapter practice
+├── learner-snapshot/ # Agent snapshot, practice summary from the agent, morning & evening coach messages
+├── daily-english/ # 150-word vocabulary, Leitner review, roleplays, local correction, backup
+├── local-speech/ # On-device Whisper transcription & pronunciation feedback
└── mission-runtime/ # Multi-step interactive mission runner & active time tracking
```
@@ -234,6 +291,10 @@ src/modules/
| `/evidence` | **Preuves** | Auditable chronological evidence stream of learner attempts |
| `/progress` | **Progression** | Honest competency graph derived strictly from verified evidence |
| `/daily-english` | **Daily English** | Daily interactive speaking and vocabulary practice |
+| `/daily-english/roleplay` | **Roleplays** | Professional English roleplays evaluated locally |
+| `/studio` | **Learning Studio** | Your own course files: cited answers, flashcards, quizzes, mock exams, audio overview |
+| `/data` | **Données** | Backup / restore and the local agent (Hermes) bridge with practice done on your phone |
+| `/presentation` | **Présentation** | 100-second product video with captions |
---
@@ -242,6 +303,7 @@ src/modules/
- 🔒 **Bounded data flow:** Proprietary documents, internal spreadsheets, private audio, and credentials stay outside Git and remote sync; only metadata classified `SYNC_ALLOWED` can enter the guarded outbox.
- 🧱 **Local automation is opt-in:** the NotebookLM browser-automation bridge (`POST /api/notebooklm/execute`) answers `404` and starts nothing unless `ELOS_NOTEBOOKLM_AUTOMATION=ENABLED` is set in the **server** environment. Even then it only accepts same-origin JSON requests addressed to `localhost`, and it refuses hosted platforms and proxied requests. Never enable it on a deployed instance.
- 🧪 **Synthetic Fixtures Only:** All automated tests use 100% synthetic, non-sensitive fixtures (`TRAINING_SYNTHETIC`).
+- 🤖 **Agent boundaries:** the MCP server only reads the course folder you configure and refuses private folders and secrets. Anything it returns is sent by the agent to *its* model provider — the snapshot it shares never contains your sentences, transcriptions, notes or answers.
- 🌐 **Offline by Default:** Document extraction, audio recording, quiz grading, and competency derivation run entirely on your local machine without mandatory network calls.
---
@@ -250,9 +312,9 @@ src/modules/
```text
learning-os, local-first, privacy-first, edtech, deliberate-practice,
-spaced-repetition, leitner-system, competency-tracking, technical-english,
-english-learning, vocabulary-builder, document-extraction, docling, pdfjs,
-nextjs, react, typescript, tailwindcss, indexeddb, cybersecurity-education
+spaced-repetition, fsrs, competency-tracking, technical-english,
+english-learning, mcp, model-context-protocol, ai-tutor, ocr, pdfjs,
+nextjs, react, typescript, indexeddb, cybersecurity-education
```
---
diff --git a/docs/integrations/HERMES.md b/docs/integrations/HERMES.md
index 304faeb..f7a985d 100644
--- a/docs/integrations/HERMES.md
+++ b/docs/integrations/HERMES.md
@@ -125,3 +125,21 @@ hermes cron create "0 8 * * *" --name "ELOS coach du matin" --script elos-mornin
Avec une messagerie configurée dans Hermes (`hermes gateway setup`), remplace `--deliver local` par `telegram`,
`signal`… Le contenu (vocabulaire, jeux de rôle, intitulés de notions) est générique ; aucun passage de cours n’y figure.
+
+### Révision du soir
+
+`scripts/evening-review.ts` lit `elos-practice.json` (écrit par le serveur après chaque QCM fait avec l’agent) et
+liste les chapitres pratiqués dans la journée et les questions ratées à refaire. L’apprenant répond « repose mes
+questions ratées » : le tuteur appelle `elos_course_exercise` avec `retry: true`, qui repose la dernière question
+ratée encore disponible. Le message ne contient que des intitulés de chapitres et des comptes.
+
+```bash
+cat > ~/.hermes/scripts/elos-evening-review.sh <<'SH'
+#!/bin/bash
+cd "$HOME/Desktop/Developer/daily-english-mission" || exit 1
+ELOS_STATE_FILE="$HOME/ELOS-Hermes/elos-state.json" exec "$(command -v node)" --experimental-strip-types --no-warnings \
+ --import ./tests/register-loader.mjs scripts/evening-review.ts
+SH
+chmod +x ~/.hermes/scripts/elos-evening-review.sh
+hermes cron create "0 19 * * *" --name "ELOS révision du soir" --script elos-evening-review.sh --no-agent --deliver telegram,whatsapp
+```
diff --git a/mcp/elos-server.ts b/mcp/elos-server.ts
index a83fe7d..3e2ddaa 100644
--- a/mcp/elos-server.ts
+++ b/mcp/elos-server.ts
@@ -451,10 +451,26 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer {
server.registerTool("elos_course_exercise", {
title: "Practice exercise with answer revealed after the attempt",
- description: `Practice on a programme chapter. With chapter_id: one exercise, statement only. By default (source "courses") a multiple-choice question built from the learner's own course files, with a page citation, preferring questions missed before; otherwise (or with source "base") an exercise of the pedagogical base. Multiple-choice questions that failed the quality audit are never served. With exercise_id and the learner's answer (a letter A-D or the option text for a multiple-choice question): the correction; course questions are graded and logged as practice (DERIVED_PRACTICE, never mastery). Never reveal a solution before the learner has answered. ${baseNote}`,
- inputSchema: { chapter_id: z.string().optional(), exercise_id: z.string().optional(), answer: z.string().min(1).max(5000).optional(), source: z.enum(["courses", "base"]).default("courses") },
- }, async ({ chapter_id, exercise_id, answer, source }) => {
+ description: `Practice on a programme chapter. With chapter_id: one exercise, statement only. By default (source "courses") a multiple-choice question built from the learner's own course files, with a page citation, preferring questions missed before; otherwise (or with source "base") an exercise of the pedagogical base. Multiple-choice questions that failed the quality audit are never served. With retry: true: a missed course question to redo (optionally limited to chapter_id). With exercise_id and the learner's answer (a letter A-D or the option text for a multiple-choice question): the correction; course questions are graded and logged as practice (DERIVED_PRACTICE, never mastery). Never reveal a solution before the learner has answered. ${baseNote}`,
+ inputSchema: { chapter_id: z.string().optional(), exercise_id: z.string().optional(), answer: z.string().min(1).max(5000).optional(), source: z.enum(["courses", "base"]).default("courses"),
+ retry: z.boolean().default(false).describe("true: ask again a course question whose latest answer was wrong, any chapter (most recent first)") },
+ }, async ({ chapter_id, exercise_id, answer, source, retry }) => {
const bases = loadBases(env);
+ if (retry && !exercise_id) {
+ const latest = new Map();
+ for (const entry of root ? readPractice(practiceLogFile(root, env)) : []) latest.set(entry.questionId, entry);
+ const missed = [...latest.values()].filter((entry) => !entry.correct && (!chapter_id || entry.chapterId === chapter_id)).sort((a, b) => b.at.localeCompare(a.at));
+ const documents = missed.length ? await loadSources(root, env) : [];
+ for (const entry of missed) {
+ const parsed = parseSourceQuestionId(entry.questionId);
+ const chapter = parsed && findChapter(parsed[0]);
+ const question = chapter && chapterSourceQuiz(documents, chapter, 50).find((item) => item.cardId === parsed[1]);
+ if (!chapter || !question) continue;
+ return json({ exercise_id: entry.questionId, type: "qcm_from_courses", retry: true, chapter: chapter.title, missedRemaining: missed.length, prompt: question.prompt,
+ options: question.choices.map((choice, index) => `${String.fromCharCode(65 + index)}. ${choice}`) });
+ }
+ return json({ retry: true, message: "No missed course question waiting: suggest a new chapter (elos_course_map)." });
+ }
if (exercise_id?.startsWith("src:")) {
const parsed = parseSourceQuestionId(exercise_id);
const chapter = parsed && findChapter(parsed[0]);
diff --git a/scripts/evening-review.ts b/scripts/evening-review.ts
new file mode 100644
index 0000000..bf928c6
--- /dev/null
+++ b/scripts/evening-review.ts
@@ -0,0 +1,12 @@
+// Prints the ELOS evening review from the practice summary the agent writes next to the
+// learner snapshot (elos-practice.json). No model, no network. Scheduled by Hermes:
+// see docs/integrations/HERMES.md.
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { AGENT_PRACTICE_FILE, parseAgentPractice, type AgentPractice } from "../src/modules/learner-snapshot/agent-practice.ts";
+import { buildEveningMessage } from "../src/modules/learner-snapshot/coach.ts";
+
+const stateFile = process.env.ELOS_STATE_FILE ?? path.join(process.env.HOME ?? "", "ELOS-Hermes", "elos-state.json");
+let practice: AgentPractice | null = null;
+try { practice = parseAgentPractice(readFileSync(path.join(path.dirname(stateFile), AGENT_PRACTICE_FILE), "utf8")); } catch { practice = null; }
+console.log(buildEveningMessage(practice, new Date()));
diff --git a/src/modules/learner-snapshot/coach.ts b/src/modules/learner-snapshot/coach.ts
index ab8887a..2b2e50a 100644
--- a/src/modules/learner-snapshot/coach.ts
+++ b/src/modules/learner-snapshot/coach.ts
@@ -1,5 +1,6 @@
import { VOCABULARY } from "../daily-english/vocabulary";
import { ROLEPLAY_SCENARIOS } from "../daily-english/roleplay";
+import type { AgentPractice } from "./agent-practice";
import type { LearnerSnapshot } from "./core";
/**
@@ -52,3 +53,31 @@ export function buildMorningMessage(snapshot: LearnerSnapshot | null, now: Date)
lines.push("", "https://daily-english-mission-ten.vercel.app/studio");
return lines.join("\n");
}
+
+/**
+ * Evening review built from the practice done with the agent (elos-practice.json):
+ * what to redo before sleeping, retrieval first. Counts and chapter titles only.
+ */
+export function buildEveningMessage(practice: AgentPractice | null, now: Date): string {
+ const date = now.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" });
+ const lines = [`🌙 ELOS — révision du soir, ${date}`];
+ const today = now.toISOString().slice(0, 10);
+ const chapters = practice?.chapters ?? [];
+ const practisedToday = chapters.filter((chapter) => chapter.lastAt.startsWith(today));
+ const retry = chapters.filter((chapter) => chapter.toRetry > 0).sort((a, b) => b.toRetry - a.toRetry || b.lastAt.localeCompare(a.lastAt));
+
+ if (practisedToday.length) {
+ lines.push(`✅ Aujourd’hui : ${practisedToday.map((chapter) => chapter.title).join(", ")}.`);
+ }
+ if (retry.length) {
+ lines.push("", "↩️ À refaire avant de dormir (un rappel le soir aide à retenir) :");
+ for (const chapter of retry.slice(0, 3)) lines.push(` • ${chapter.title} : ${chapter.toRetry} question${chapter.toRetry > 1 ? "s" : ""} ratée${chapter.toRetry > 1 ? "s" : ""}`);
+ lines.push("", "Réponds « repose mes questions ratées » pour les refaire maintenant.");
+ } else if (chapters.length) {
+ lines.push("", "👏 Aucune question ratée en attente. Réponds « un QCM sur … » avec un chapitre pour avancer.");
+ } else {
+ lines.push("", "Pas encore d’entraînement avec moi. Réponds « quel est mon programme ? » puis « un QCM sur … » pour commencer.");
+ }
+ lines.push("", "C’est de l’entraînement : la maîtrise se prouve dans ELOS, sans aide.");
+ return lines.join("\n");
+}
diff --git a/tests/unit/learner-snapshot/coach.test.ts b/tests/unit/learner-snapshot/coach.test.ts
index a8f13e2..4709a18 100644
--- a/tests/unit/learner-snapshot/coach.test.ts
+++ b/tests/unit/learner-snapshot/coach.test.ts
@@ -36,3 +36,21 @@ test("message is deterministic for a given day and rotates the word of the day",
const word = (text: string) => text.match(/Mot du jour : (.+)/)![1];
assert.notEqual(word(tomorrow), word(buildMorningMessage(base, now)));
});
+
+test("evening review: today's chapters, missed questions first, never a mastery claim", async () => {
+ const { buildEveningMessage } = await import("../../../src/modules/learner-snapshot/coach.ts");
+ const practice = {
+ format: "engineer-learning-os/agent-practice", version: 1, generatedAt: "2026-10-23T18:00:00Z", evidence: "DERIVED_PRACTICE",
+ chapters: [
+ { chapterId: "a", title: "IPv4 et masques", subject: "Réseaux", answered: 3, correct: 1, toRetry: 2, lastAt: "2026-10-23T15:00:00Z" },
+ { chapterId: "b", title: "Sprints", subject: "Projet", answered: 1, correct: 1, toRetry: 0, lastAt: "2026-10-20T15:00:00Z" },
+ ],
+ } as const;
+ const message = buildEveningMessage(practice, new Date("2026-10-23T17:00:00Z"));
+ assert.match(message, /Aujourd’hui : IPv4 et masques\./);
+ assert.match(message, /IPv4 et masques : 2 questions ratées/);
+ assert.ok(!/Sprints :/.test(message));
+ assert.match(message, /repose mes questions ratées/);
+ assert.match(message, /la maîtrise se prouve dans ELOS/);
+ assert.match(buildEveningMessage(null, new Date()), /Pas encore d’entraînement/);
+});
diff --git a/tests/unit/mcp/course-base.test.ts b/tests/unit/mcp/course-base.test.ts
index 3dbce82..91efdc5 100644
--- a/tests/unit/mcp/course-base.test.ts
+++ b/tests/unit/mcp/course-base.test.ts
@@ -112,9 +112,8 @@ test("course questions come from the learner's files, are graded after the attem
assert.ok(!JSON.stringify(question).includes("quote"), "no citation before the attempt");
assert.match(String((await call("elos_course_exercise", { exercise_id: question.exercise_id })).error), /answer first/);
- const wrong = question.options.find((option) => !/IPv4|Masque/.test(option)) ?? question.options[0];
- const graded = await call("elos_course_exercise", { exercise_id: question.exercise_id, answer: wrong.slice(0, 1) }) as { correct: boolean; citation: { quote: string }; evidence: string };
- assert.equal(typeof graded.correct, "boolean");
+ const graded = await call("elos_course_exercise", { exercise_id: question.exercise_id, answer: "aucune de ces notions" }) as { correct: boolean; citation: { quote: string }; evidence: string };
+ assert.equal(graded.correct, false);
assert.ok(graded.citation.quote.length > 0);
assert.match(graded.evidence, /not mastery/);
@@ -122,6 +121,8 @@ test("course questions come from the learner's files, are graded after the attem
assert.ok(!log.startsWith(root + path.sep), "the log lives outside the shared course folder");
assert.ok(existsSync(log));
assert.equal(readPractice(log).length, 1);
+ const retry = await call("elos_course_exercise", { retry: true }) as { exercise_id?: string; retry: boolean; message?: string };
+ assert.equal(retry.exercise_id, question.exercise_id, "the missed question comes back first");
const summary = parseAgentPractice(readFileSync(path.join(base, "elos-practice.json"), "utf8"));
assert.deepEqual([summary.chapters[0].chapterId, summary.chapters[0].answered], ["ch_ip", 1]);
const map = await call("elos_course_map") as { bases: { chapters: { practice: { answered: number } | null }[] }[] };