diff --git a/docs/integrations/HERMES.md b/docs/integrations/HERMES.md index 065cc1d..4103105 100644 --- a/docs/integrations/HERMES.md +++ b/docs/integrations/HERMES.md @@ -59,3 +59,23 @@ Garde-fous : seuls `.pdf`, `.md`, `.txt` sous ce dossier ; `knowledge-private/`, N’y mets pas de documents d’entreprise internes, de données personnelles d’autrui ni de secrets. Le serveur est lancé depuis ce dépôt : garde une branche qui contient `mcp/` (par exemple `main-public`). + +## Coach du matin (tâche planifiée Hermes, sans modèle) + +`scripts/morning-coach.ts` construit chaque matin un message à partir du résumé : série, révisions dues, question de +rappel (difficulté principale), mot d’anglais du jour, réplique de jeu de rôle, compte à rebours d’examen. Aucun modèle +d’IA, aucun réseau : le message est écrit dans `coach-du-jour.md` à côté du résumé et affiché en notification macOS. + +```bash +cat > ~/.hermes/scripts/elos-morning-coach.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/morning-coach.ts --notify +SH +chmod +x ~/.hermes/scripts/elos-morning-coach.sh +hermes cron create "0 8 * * *" --name "ELOS coach du matin" --script elos-morning-coach.sh --no-agent --deliver local +``` + +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. diff --git a/scripts/morning-coach.ts b/scripts/morning-coach.ts new file mode 100644 index 0000000..59f4b5f --- /dev/null +++ b/scripts/morning-coach.ts @@ -0,0 +1,21 @@ +// Prints the ELOS morning coach message from the learner snapshot (ELOS_STATE_FILE), +// writes it next to the snapshot (coach-du-jour.md) and, with --notify, shows a macOS +// notification. No model, no network. Scheduled by Hermes: see docs/integrations/HERMES.md. +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { buildMorningMessage } from "../src/modules/learner-snapshot/coach.ts"; +import { parseLearnerSnapshot, type LearnerSnapshot } from "../src/modules/learner-snapshot/core.ts"; + +const file = process.env.ELOS_STATE_FILE ?? path.join(process.env.HOME ?? "", "ELOS-Hermes", "elos-state.json"); +let snapshot: LearnerSnapshot | null = null; +try { snapshot = parseLearnerSnapshot(readFileSync(file, "utf8")); } catch { snapshot = null; } +const message = buildMorningMessage(snapshot, new Date()); +try { writeFileSync(path.join(path.dirname(file), "coach-du-jour.md"), message + "\n", { mode: 0o600 }); } catch { /* folder missing: stdout only */ } +if (process.argv.includes("--notify") && process.platform === "darwin") { + const summary = message.split("\n").filter((line) => /^(📚|❓|🎯)/u.test(line)).join(" ").slice(0, 220) || "Ouvre ELOS pour ta séance du jour."; + try { + execFileSync("osascript", ["-e", "on run argv", "-e", "display notification (item 1 of argv) with title \"ELOS — séance du jour\"", "-e", "end run", summary]); + } catch { /* notifications disabled: the message is still printed */ } +} +console.log(message); diff --git a/src/modules/learner-snapshot/coach.ts b/src/modules/learner-snapshot/coach.ts new file mode 100644 index 0000000..ab8887a --- /dev/null +++ b/src/modules/learner-snapshot/coach.ts @@ -0,0 +1,54 @@ +import { VOCABULARY } from "../daily-english/vocabulary"; +import { ROLEPLAY_SCENARIOS } from "../daily-english/roleplay"; +import type { LearnerSnapshot } from "./core"; + +/** + * Morning coach message built only from the learner snapshot and ELOS content, + * without any model: deterministic, free, nothing sent to an AI provider. + * Retrieval question first (recall before re-reading), then a short English + * practice and the exam countdown. + */ + +const STALE_MS = 24 * 3_600_000; + +function dayIndex(day: string): number { + return Math.floor(Date.parse(`${day}T00:00:00Z`) / 86_400_000); +} + +export function buildMorningMessage(snapshot: LearnerSnapshot | null, now: Date): string { + const date = now.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" }); + const lines = [`☀️ ELOS — ${date}`]; + if (!snapshot) { + lines.push("", "Je ne trouve pas ton résumé ELOS. Ouvre l’app (Données → Agent local) pour le partager."); + return lines.join("\n"); + } + const today = snapshot.today; + const index = dayIndex(today); + if (now.getTime() - Date.parse(snapshot.generatedAt) > STALE_MS) { + lines.push("⚠️ Résumé de plus de 24 h : ouvre ELOS pour le mettre à jour."); + } + lines.push(`🔥 Série Daily English : ${snapshot.dailyEnglish.streak} jour${snapshot.dailyEnglish.streak > 1 ? "s" : ""} · ${snapshot.dailyEnglish.wordsMastered}/${snapshot.dailyEnglish.totalWords} mots maîtrisés`); + + const cards = snapshot.studio.dueToday; + const words = snapshot.dailyEnglish.dueWords; + const workload = [cards.length ? `${cards.length} carte${cards.length > 1 ? "s" : ""} de cours` : null, words.length ? `${words.length} mot${words.length > 1 ? "s" : ""} d’anglais` : null].filter(Boolean); + lines.push("", workload.length ? `📚 À réviser aujourd’hui : ${workload.join(" · ")}` : "📚 Rien de dû aujourd’hui : profites-en pour une mission Daily English."); + + const recall = snapshot.studio.difficulties[0]?.term ?? cards[index % Math.max(1, cards.length)]?.term ?? null; + if (recall) lines.push(`❓ Rappel actif : sans regarder, que désigne « ${recall} » ? Vérifie ensuite dans le Studio.`); + + const word = VOCABULARY[((index % VOCABULARY.length) + VOCABULARY.length) % VOCABULARY.length]; + lines.push("", `🇬🇧 Mot du jour : ${word.word} (${word.translation})`, ` « ${word.example} »`); + + const scenario = ROLEPLAY_SCENARIOS[((index % ROLEPLAY_SCENARIOS.length) + ROLEPLAY_SCENARIOS.length) % ROLEPLAY_SCENARIOS.length]; + const turn = scenario.turns[0]; + lines.push(`🗣️ À l’oral (${scenario.title}) : « ${turn.interlocutor} »`, ` ${turn.task}`); + + const exam = snapshot.exams[0]; + if (exam) { + const advice = exam.daysLeft <= 1 ? "révision légère + flashcards" : exam.daysLeft <= 3 ? "examen blanc chronométré" : exam.daysLeft <= 7 ? "rappel actif sur le cours dans le Studio" : "révisions espacées régulières"; + lines.push("", `🎯 ${exam.course} dans ${exam.daysLeft} jour${exam.daysLeft > 1 ? "s" : ""} : ${advice}.`); + } + lines.push("", "https://daily-english-mission-ten.vercel.app/studio"); + return lines.join("\n"); +} diff --git a/tests/unit/learner-snapshot/coach.test.ts b/tests/unit/learner-snapshot/coach.test.ts new file mode 100644 index 0000000..a8f13e2 --- /dev/null +++ b/tests/unit/learner-snapshot/coach.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildMorningMessage } from "../../../src/modules/learner-snapshot/coach.ts"; +import type { LearnerSnapshot } from "../../../src/modules/learner-snapshot/core.ts"; + +const base: LearnerSnapshot = { + format: "engineer-learning-os/learner-snapshot", version: 1, generatedAt: "2026-10-23T05:00:00.000Z", today: "2026-10-23", + dailyEnglish: { streak: 4, wordsSeen: 30, wordsMastered: 3, totalWords: 150, todayMission: null, dueWords: [{ word: "embrace", translation: "accepter", due: "2026-10-23" }] }, + studio: { scheduler: "CURRENT_V1", dueToday: [{ term: "TCP", source: "Cours" }, { term: "UDP", source: "Cours" }], next7Days: [], difficulties: [{ term: "clé étrangère", errors: 2, attempts: 3 }], schedulerReplay: { predictions: 0, enough: false, logLoss: {} } }, + exams: [{ course: "Base de données SQL", date: "2026-10-26", priority: "P1", daysLeft: 3 }], + privacy: { excludes: [] }, +}; + +test("morning message: streak, workload, recall on the top difficulty, English practice, exam advice", () => { + const message = buildMorningMessage(base, new Date("2026-10-23T06:00:00Z")); + assert.match(message, /Série Daily English : 4 jours/); + assert.match(message, /2 cartes de cours · 1 mot d’anglais/); + assert.match(message, /que désigne « clé étrangère »/); + assert.match(message, /Mot du jour : /); + assert.match(message, /À l’oral \(/); + assert.match(message, /Base de données SQL dans 3 jours : examen blanc chronométré/); + assert.ok(!/⚠️/.test(message)); +}); + +test("stale or missing snapshots are stated, nothing is invented", () => { + assert.match(buildMorningMessage(base, new Date("2026-10-25T08:00:00Z")), /plus de 24 h/); + const missing = buildMorningMessage(null, new Date()); + assert.match(missing, /Je ne trouve pas ton résumé ELOS/); + assert.ok(!/À réviser/.test(missing)); +}); + +test("message is deterministic for a given day and rotates the word of the day", () => { + const now = new Date("2026-10-23T06:00:00Z"); + assert.equal(buildMorningMessage(base, now), buildMorningMessage(base, now)); + const tomorrow = buildMorningMessage({ ...base, today: "2026-10-24", generatedAt: "2026-10-24T05:00:00.000Z" }, new Date("2026-10-24T06:00:00Z")); + const word = (text: string) => text.match(/Mot du jour : (.+)/)![1]; + assert.notEqual(word(tomorrow), word(buildMorningMessage(base, now))); +});